markdown support for talk messages

This commit is contained in:
2026-08-16 21:35:41 +02:00
parent bd83a88e70
commit 02a7b87b5b
14 changed files with 379 additions and 19 deletions
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../data/chat_bubble_styles.dart';
import '../data/talk_markdown.dart';
class AnswerReference extends StatelessWidget {
final GetChatResponseObject referenceMessage;
@@ -43,10 +44,15 @@ class AnswerReference extends StatelessWidget {
),
),
Text(
RichObjectStringProcessor.parseToString(
referenceMessage.message,
referenceMessage.messageParameters,
),
() {
final text = RichObjectStringProcessor.parseToString(
referenceMessage.message,
referenceMessage.messageParameters,
);
return referenceMessage.markdown
? markdownToPlainText(text)
: text;
}(),
maxLines: 2,
style: TextStyle(
overflow: TextOverflow.ellipsis,
+5 -1
View File
@@ -157,7 +157,7 @@ class _ChatBubbleState extends State<ChatBubble>
/// `onTap: null` on the bubble's `GestureDetector` so its
/// `TapGestureRecognizer` does not enter the gesture arena — otherwise
/// it competes with (and blocks) the per-link `TapGestureRecognizer`s
/// that `HighlightedLinkify` attaches to URL spans.
/// that `HighlightedLinkify` / the Markdown renderer attach to link spans.
bool get _hasTapAction {
final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) return true;
@@ -280,6 +280,10 @@ class _ChatBubbleState extends State<ChatBubble>
messageWidget: message.getWidget(
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
renderMarkdown:
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment,
),
parent: parent,
bubbleData: widget.bubbleData,
+14 -1
View File
@@ -20,6 +20,7 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../data/talk_markdown.dart';
import '../talk_navigator.dart';
class ChatTile extends StatefulWidget {
@@ -62,6 +63,18 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh();
/// One-line preview of the last message: rich-object placeholders resolved,
/// newlines flattened and — for Markdown messages — formatting stripped so
/// the list shows readable text rather than raw markers.
String _lastMessagePreview() {
final last = widget.data.lastMessage;
final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '),
last.messageParameters,
);
return last.markdown ? markdownToPlainText(text) : text;
}
Future<void> _setCurrentAsRead() async {
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
@@ -142,7 +155,7 @@ class _ChatTileState extends State<ChatTile> {
),
subtitle: Text(
'${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: '
'${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}',
'${_lastMessagePreview()}',
overflow: TextOverflow.ellipsis,
),
trailing: widget.data.unreadMessages <= 0
@@ -0,0 +1,118 @@
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,
),
),
);
}
}