From a2e5a940bdd64877b63ce467aba1a7b35fe4365c Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sat, 10 May 2025 16:27:58 +0200 Subject: [PATCH 1/2] feat: Check markdown checkboxes in messages --- lib/pages/chat/events/html_message.dart | 70 ++++++++++++++++++++-- lib/pages/chat/events/message_content.dart | 6 ++ lib/utils/event_checkbox_extension.dart | 27 +++++++++ pubspec.lock | 9 +-- pubspec.yaml | 7 ++- 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 lib/utils/event_checkbox_extension.dart diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 7aca3154b..5e57b128c 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -8,7 +8,9 @@ import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as parser; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/utils/event_checkbox_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../utils/url_launcher.dart'; @@ -19,6 +21,8 @@ class HtmlMessage extends StatelessWidget { final double fontSize; final TextStyle linkStyle; final void Function(LinkableElement) onOpen; + final String? eventId; + final Set? checkboxCheckedEvents; const HtmlMessage({ super.key, @@ -28,6 +32,8 @@ class HtmlMessage extends StatelessWidget { required this.linkStyle, this.textColor = Colors.black, required this.onOpen, + this.eventId, + this.checkboxCheckedEvents, }); /// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes @@ -218,6 +224,24 @@ class HtmlMessage extends StatelessWidget { if (!{'ol', 'ul'}.contains(node.parent?.localName)) { continue block; } + final eventId = this.eventId; + + final isCheckbox = node.className == 'task-list-item'; + final checkboxIndex = isCheckbox + ? node.rootElement + .getElementsByClassName('task-list-item') + .indexOf(node) + + 1 + : null; + final checkedByReaction = !isCheckbox + ? null + : checkboxCheckedEvents?.firstWhereOrNull( + (event) => event.checkedCheckboxId == checkboxIndex, + ); + final staticallyChecked = !isCheckbox + ? false + : node.children.first.attributes['checked'] == 'true'; + return WidgetSpan( child: Padding( padding: EdgeInsets.only(left: fontSize), @@ -231,6 +255,42 @@ class HtmlMessage extends StatelessWidget { text: '${(node.parent?.nodes.whereType().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ', ), + if (node.className == 'task-list-item') + WidgetSpan( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox.square( + dimension: fontSize, + child: Checkbox.adaptive( + checkColor: textColor, + side: BorderSide(color: textColor), + activeColor: textColor.withAlpha(64), + visualDensity: VisualDensity.compact, + value: + staticallyChecked || checkedByReaction != null, + onChanged: eventId == null || + checkboxIndex == null || + staticallyChecked || + !room.canSendDefaultMessages || + (checkedByReaction != null && + checkedByReaction.senderId != + room.client.userID) + ? null + : (_) => showFutureLoadingDialog( + context: context, + future: () => checkedByReaction != null + ? room.redactEvent( + checkedByReaction.eventId, + ) + : room.checkCheckbox( + eventId, + checkboxIndex, + ), + ), + ), + ), + ), + ), ..._renderWithLineBreaks( node.nodes, context, @@ -446,11 +506,9 @@ class HtmlMessage extends StatelessWidget { @override Widget build(BuildContext context) { + final element = parser.parse(html).body ?? dom.Element.html(''); return Text.rich( - _renderHtml( - parser.parse(html).body ?? dom.Element.html(''), - context, - ), + _renderHtml(element, context), style: TextStyle( fontSize: fontSize, color: textColor, @@ -516,3 +574,7 @@ extension on String { return colorValue == null ? null : Color(colorValue); } } + +extension on dom.Element { + dom.Element get rootElement => parent?.rootElement ?? this; +} diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 116aaf4ef..8c72cae3b 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../../config/app_config.dart'; +import '../../../utils/event_checkbox_extension.dart'; import '../../../utils/platform_infos.dart'; import '../../../utils/url_launcher.dart'; import '../../bootstrap/bootstrap_dialog.dart'; @@ -204,6 +205,11 @@ class MessageContent extends StatelessWidget { decorationColor: linkColor, ), onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + eventId: event.eventId, + checkboxCheckedEvents: event.aggregatedEvents( + timeline, + EventCheckboxRoomExtension.relationshipType, + ), ), ); } diff --git a/lib/utils/event_checkbox_extension.dart b/lib/utils/event_checkbox_extension.dart new file mode 100644 index 000000000..cf3832ba6 --- /dev/null +++ b/lib/utils/event_checkbox_extension.dart @@ -0,0 +1,27 @@ +import 'package:matrix/matrix.dart'; + +extension EventCheckboxRoomExtension on Room { + static const String relationshipType = 'im.fluffychat.checkboxes'; + Future checkCheckbox( + String eventId, + int checkboxId, { + String? txid, + }) => + sendEvent( + { + 'm.relates_to': { + 'rel_type': relationshipType, + 'event_id': eventId, + 'checkbox_id': checkboxId, + }, + }, + type: EventTypes.Reaction, + txid: txid, + ); +} + +extension EventCheckboxExtension on Event { + int? get checkedCheckboxId => content + .tryGetMap('m.relates_to') + ?.tryGet('checkbox_id'); +} diff --git a/pubspec.lock b/pubspec.lock index f1c892e49..084b31477 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1150,10 +1150,11 @@ packages: matrix: dependency: "direct main" description: - name: matrix - sha256: "7d15fdbc760be7e40c58bb65e03baa8241b1e31db2bc67dab61883aabc083a85" - url: "https://pub.dev" - source: hosted + path: "." + ref: "krille/add-markdown-checkboxes" + resolved-ref: f3bb654ac2cda19bdd8a35fb46846018acd01a89 + url: "https://github.com/famedly/matrix-dart-sdk.git" + source: git version: "0.40.0" meta: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index a3fc68d61..61efd69b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,10 @@ dependencies: just_audio: ^0.9.39 latlong2: ^0.9.1 linkify: ^5.0.0 - matrix: ^0.40.0 + matrix: + git: + url: https://github.com/famedly/matrix-dart-sdk.git + ref: krille/add-markdown-checkboxes mime: ^1.0.6 native_imaging: ^0.2.0 opus_caf_converter_dart: ^1.0.1 @@ -140,4 +143,4 @@ dependency_overrides: url: https://github.com/ThexXTURBOXx/flutter_web_auth_2.git ref: 3.x-without-v1 path: flutter_web_auth_2 - win32: 5.5.3 + win32: 5.5.3 \ No newline at end of file From 9da7a5704e17392f4ac33f60cd3c5feac0b0bb77 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sat, 10 May 2025 16:54:29 +0200 Subject: [PATCH 2/2] feat: Create lists with checkboxes via + menu --- assets/l10n/intl_en.arb | 1 + lib/pages/chat/chat.dart | 49 +++++++++++++++++++++++++----- lib/pages/chat/chat_input_row.dart | 12 ++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index c1ffb8627..40a1c3f7e 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -696,6 +696,7 @@ } } }, + "checkList": "Check list", "countParticipants": "{count} participants", "@countParticipants": { "type": "String", diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 1103c47fa..dd1d16c91 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -275,13 +275,44 @@ class ChatController extends State ); } - KeyEventResult _shiftEnterKeyHandling(FocusNode node, KeyEvent evt) { + KeyEventResult _customEnterKeyHandling(FocusNode node, KeyEvent evt) { if (!HardwareKeyboard.instance.isShiftPressed && - evt.logicalKey.keyLabel == 'Enter') { + evt.logicalKey.keyLabel == 'Enter' && + (AppConfig.sendOnEnter ?? !PlatformInfos.isMobile)) { if (evt is KeyDownEvent) { send(); } return KeyEventResult.handled; + } else if (evt.logicalKey.keyLabel == 'Enter' && evt is KeyDownEvent) { + final currentLineNum = sendController.text + .substring( + 0, + sendController.selection.baseOffset, + ) + .split('\n') + .length - + 1; + final currentLine = sendController.text.split('\n')[currentLineNum]; + + for (final pattern in [ + '- [ ] ', + '- [x] ', + '* [ ] ', + '* [x] ', + '- ', + '* ', + '+ ', + ]) { + if (currentLine.startsWith(pattern)) { + if (currentLine == pattern) { + return KeyEventResult.ignored; + } + sendController.text += '\n$pattern'; + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; } else { return KeyEventResult.ignored; } @@ -289,11 +320,7 @@ class ChatController extends State @override void initState() { - inputFocus = FocusNode( - onKeyEvent: (AppConfig.sendOnEnter ?? !PlatformInfos.isMobile) - ? _shiftEnterKeyHandling - : null, - ); + inputFocus = FocusNode(onKeyEvent: _customEnterKeyHandling); scrollController.addListener(_updateScrollController); inputFocus.addListener(_inputFocusListener); @@ -1154,6 +1181,14 @@ class ChatController extends State if (choice == 'location') { sendLocationAction(); } + if (choice == 'checklist') { + if (sendController.text.isEmpty) { + sendController.text = '- [ ] '; + } else { + sendController.text += '\n- [ ] '; + } + inputFocus.requestFocus(); + } } unpinEvent(String eventId) async { diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 88860a77e..b07d2ae38 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -123,6 +123,18 @@ class ChatInputRow extends StatelessWidget { onSelected: controller.onAddPopupMenuButtonSelected, itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: 'checklist', + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.onPrimaryContainer, + foregroundColor: theme.colorScheme.primaryContainer, + child: const Icon(Icons.check_circle_outlined), + ), + title: Text(L10n.of(context).checkList), + contentPadding: const EdgeInsets.all(0), + ), + ), if (PlatformInfos.isMobile) PopupMenuItem( value: 'location',