diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 24f57fe37..136b99475 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -89,10 +89,13 @@ class ComposeBar extends HookConsumerWidget { // Typing indicator broadcast — throttled to one event per 3 seconds. final lastTypingSentMs = useRef(0); + final isModifyingText = useRef(false); // Detect @mention query and broadcast typing on text / selection change. useEffect(() { void listener() { + if (isModifyingText.value) return; + if (_expandFenceIfNeeded(controller, isModifyingText)) return; final text = controller.text; final sel = controller.selection; @@ -316,26 +319,31 @@ class ComposeBar extends HookConsumerWidget { final sel = controller.selection; if (!sel.isValid) return; - if (sel.isCollapsed) { - final offset = sel.baseOffset; - const open = '```\n'; - const close = '\n```'; - final updated = - '${text.substring(0, offset)}$open$close${text.substring(offset)}'; - controller.text = updated; - controller.selection = TextSelection.collapsed( - offset: offset + open.length, - ); - } else { - final selected = text.substring(sel.start, sel.end); - const open = '```\n'; - const close = '\n```'; - final updated = - '${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}'; - controller.text = updated; - controller.selection = TextSelection.collapsed( - offset: sel.start + open.length + selected.length + close.length, - ); + isModifyingText.value = true; + try { + if (sel.isCollapsed) { + final offset = sel.baseOffset; + const open = '```\n'; + const close = '\n```'; + final updated = + '${text.substring(0, offset)}$open$close${text.substring(offset)}'; + controller.text = updated; + controller.selection = TextSelection.collapsed( + offset: offset + open.length, + ); + } else { + final selected = text.substring(sel.start, sel.end); + const open = '```\n'; + const close = '\n```'; + final updated = + '${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}'; + controller.text = updated; + controller.selection = TextSelection.collapsed( + offset: sel.start + open.length + selected.length + close.length, + ); + } + } finally { + isModifyingText.value = false; } focusNode.requestFocus(); } @@ -584,6 +592,51 @@ void spliceAndMoveCursor( focusNode.requestFocus(); } +bool _expandFenceIfNeeded( + TextEditingController controller, + ObjectRef guard, +) { + final text = controller.text; + final sel = controller.selection; + if (!sel.isValid || !sel.isCollapsed) return false; + final cursor = sel.baseOffset; + if (cursor == 0) return false; + if (text[cursor - 1] != '\n') return false; + + var lineStart = 0; + for (var i = cursor - 2; i >= 0; i--) { + if (text[i] == '\n') { + lineStart = i + 1; + break; + } + } + + final line = text.substring(lineStart, cursor - 1); + final match = RegExp(r'^```([a-zA-Z+#]*)$').firstMatch(line); + if (match == null) return false; + + final before = text.substring(0, lineStart); + var fenceCount = 0; + var searchFrom = 0; + while (true) { + final idx = before.indexOf('```', searchFrom); + if (idx == -1) break; + fenceCount++; + searchFrom = idx + 3; + } + if (fenceCount.isOdd) return false; + + final lang = match.group(1)!; + final newText = + '${text.substring(0, lineStart)}```$lang\n\n```${text.substring(cursor)}'; + final cursorPos = lineStart + '```$lang\n'.length; + guard.value = true; + controller.text = newText; + controller.selection = TextSelection.collapsed(offset: cursorPos); + guard.value = false; + return true; +} + /// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with /// a space if needed for word separation. Used by `triggerMention` and /// `triggerChannel`. diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 57d1cf2ac..4bf862479 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -120,6 +121,8 @@ class MessageContent extends StatelessWidget { finalContent, style: style, followLinkColor: false, + codeBuilder: (context, name, code, closed) => + _MessageCodeBlock(name: name, code: code), linkBuilder: (context, linkText, url, linkStyle) => _buildLink(context, linkText, url, linkStyle, style), imageBuilder: (context, imageUrl) => @@ -443,6 +446,116 @@ class _MediaPreviewFallback extends StatelessWidget { } } +class _MessageCodeBlock extends StatefulWidget { + final String name; + final String code; + + const _MessageCodeBlock({required this.name, required this.code}); + + @override + State<_MessageCodeBlock> createState() => _MessageCodeBlockState(); +} + +class _MessageCodeBlockState extends State<_MessageCodeBlock> { + bool _copied = false; + + Future _handleCopy() async { + await Clipboard.setData(ClipboardData(text: widget.code)); + if (!mounted) return; + setState(() => _copied = true); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied code to clipboard'), + duration: Duration(seconds: 2), + ), + ); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _copied = false); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: Grid.half), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: context.colors.outline.withValues(alpha: 0.7), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.name.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: Grid.twelve, + top: Grid.half + Grid.quarter, + ), + child: Text( + widget.name, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + Stack( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + Grid.twelve, + widget.name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter, + 44, + Grid.half + Grid.quarter, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + widget.code, + softWrap: false, + style: TextStyle( + fontFamily: 'GeistMono', + fontSize: 13, + height: 1.5, + color: context.colors.onSurface, + ), + ), + ), + ), + Positioned( + top: 0, + right: Grid.quarter, + child: SizedBox( + width: 28, + height: 28, + child: IconButton( + onPressed: _handleCopy, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + _copied ? LucideIcons.check : LucideIcons.copy, + size: 14, + color: _copied + ? context.colors.primary + : context.colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + class _MentionMd extends InlineMd { final Map mentionNames; late final RegExp _exp = _buildPrefixPattern(