Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 73 additions & 20 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -584,6 +592,51 @@ void spliceAndMoveCursor(
focusNode.requestFocus();
}

bool _expandFenceIfNeeded(
TextEditingController controller,
ObjectRef<bool> 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`.
Expand Down
113 changes: 113 additions & 0 deletions mobile/lib/features/channels/message_content.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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<void> _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<String, String> mentionNames;
late final RegExp _exp = _buildPrefixPattern(
Expand Down