From 6325a09c8a9770074379dd97b7338cf5410b9bb6 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Sat, 11 Jan 2025 14:42:05 +0100 Subject: [PATCH] fix: correctly shift enter and enter send, more fade animations + create timeline model --- lib/chat/chat_model.dart | 41 ----------- lib/chat/draft_model.dart | 8 +-- lib/chat/timeline_model.dart | 43 +++++++++++ .../chat_room_info_drawer_topic.dart | 4 +- .../chat_room_master_tile_subtitle.dart | 61 ++++++++++------ lib/chat/view/chat_room/chat_room_page.dart | 37 ++++++---- ...list.dart => chat_room_timeline_list.dart} | 71 ++++++++----------- lib/chat/view/chat_room/input/chat_input.dart | 52 ++++++++------ lib/chat/view/events/chat_html_message.dart | 13 ++-- lib/register.dart | 5 ++ 10 files changed, 185 insertions(+), 150 deletions(-) create mode 100644 lib/chat/timeline_model.dart rename lib/chat/view/chat_room/{chat_timeline_list.dart => chat_room_timeline_list.dart} (77%) diff --git a/lib/chat/chat_model.dart b/lib/chat/chat_model.dart index 75e7c92..ee8fff6 100644 --- a/lib/chat/chat_model.dart +++ b/lib/chat/chat_model.dart @@ -163,9 +163,6 @@ class ChatModel extends SafeChangeNotifier { Room? get selectedRoom => _selectedRoom; Future setSelectedRoom(Room? value) async { _selectedRoom = value; - if (value == null) { - _roomSearchActive = false; - } notifyListeners(); } @@ -370,42 +367,4 @@ class ChatModel extends SafeChangeNotifier { _setProcessingJoinOrLeave(false); } } - - // TIMELINES - - bool _updatingTimeline = false; - bool get updatingTimeline => _updatingTimeline; - void setUpdatingTimeline(bool value) { - if (value == _updatingTimeline) return; - _updatingTimeline = value; - notifyListeners(); - } - - Future requestHistory( - Timeline timeline, { - int historyCount = Room.defaultHistoryCount, - StateFilter? filter, - bool notify = true, - }) async { - if (notify) { - setUpdatingTimeline(true); - } - if (timeline.isRequestingHistory) { - setUpdatingTimeline(false); - return; - } - await timeline.requestHistory(filter: filter, historyCount: historyCount); - if (notify) { - setUpdatingTimeline(false); - } - } - - bool _roomSearchActive = false; - bool get roomSearchActive => _roomSearchActive; - void toggleRoomSearch({bool? value}) { - bool theValue = value ?? !_roomSearchActive; - if (theValue == _roomSearchActive) return; - _roomSearchActive = theValue; - notifyListeners(); - } } diff --git a/lib/chat/draft_model.dart b/lib/chat/draft_model.dart index 31c4ac9..8ab5836 100644 --- a/lib/chat/draft_model.dart +++ b/lib/chat/draft_model.dart @@ -79,11 +79,12 @@ class DraftModel extends SafeChangeNotifier { } } - final draft = getDraft(room.id); - if (draft?.isNotEmpty ?? false) { + final draft = '${getDraft(room.id)}'; + removeDraft(room.id); + if (draft.isNotEmpty) { try { await room.sendTextEvent( - draft!.trim(), + draft.trim(), inReplyTo: replyEvent, editEventId: _editEvents[room.id]?.eventId, ); @@ -96,7 +97,6 @@ class DraftModel extends SafeChangeNotifier { _sending = false; _replyEvent = null; _editEvents[room.id] = null; - removeDraft(room.id); notifyListeners(); } diff --git a/lib/chat/timeline_model.dart b/lib/chat/timeline_model.dart new file mode 100644 index 0000000..651cdc7 --- /dev/null +++ b/lib/chat/timeline_model.dart @@ -0,0 +1,43 @@ +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +class TimelineModel extends SafeChangeNotifier { + // TIMELINES + + bool _updatingTimeline = false; + bool get updatingTimeline => _updatingTimeline; + void setUpdatingTimeline(bool value) { + if (value == _updatingTimeline) return; + _updatingTimeline = value; + notifyListeners(); + } + + Future requestHistory( + Timeline timeline, { + int historyCount = 350, + StateFilter? filter, + bool notify = true, + }) async { + if (notify) { + setUpdatingTimeline(true); + } + if (timeline.isRequestingHistory) { + setUpdatingTimeline(false); + return; + } + await timeline.requestHistory(filter: filter, historyCount: historyCount); + await timeline.setReadMarker(); + if (notify) { + setUpdatingTimeline(false); + } + } + + bool _timelineSearchActive = false; + bool get timelineSearchActive => _timelineSearchActive; + void toggleTimelineSearch({bool? value}) { + bool theValue = value ?? !_timelineSearchActive; + if (theValue == _timelineSearchActive) return; + _timelineSearchActive = theValue; + notifyListeners(); + } +} diff --git a/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart b/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart index 6d2c91b..8062073 100644 --- a/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart +++ b/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart @@ -4,7 +4,7 @@ import 'package:matrix/matrix.dart'; import 'package:watch_it/watch_it.dart'; import '../../../common/view/ui_constants.dart'; -import '../../chat_model.dart'; +import '../../timeline_model.dart'; class ChatRoomInfoDrawerTopic extends StatelessWidget with WatchItMixin { const ChatRoomInfoDrawerTopic({ @@ -17,7 +17,7 @@ class ChatRoomInfoDrawerTopic extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final updatingTimeline = - watchPropertyValue((ChatModel m) => m.updatingTimeline); + watchPropertyValue((TimelineModel m) => m.updatingTimeline); return SliverToBoxAdapter( child: Center( diff --git a/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart b/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart index 9c29145..68e6ed3 100644 --- a/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart +++ b/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart @@ -24,22 +24,22 @@ class ChatRoomMasterTileSubTitle extends StatelessWidget with WatchItMixin { initialValue: room.lastEvent, ).data; - return typingUsers.isEmpty - ? _LastEvent( - key: ObjectKey(lastEvent), - lastEvent: lastEvent ?? room.lastEvent, - fallbackText: - room.membership == Membership.invite ? room.name : null, - ) - : Text( - typingUsers.length > 1 - ? context.l10n.numUsersTyping(typingUsers.length) - : context.l10n - .userIsTyping(typingUsers.first.displayName ?? ''), - style: context.textTheme.bodyMedium - ?.copyWith(color: context.colorScheme.primary), - maxLines: 1, - ); + if (typingUsers.isEmpty) { + return _LastEvent( + key: ValueKey('${lastEvent?.eventId}lastevent'), + lastEvent: room.lastEvent, + fallbackText: room.membership == Membership.invite ? room.name : null, + ); + } + + return Text( + typingUsers.length > 1 + ? context.l10n.numUsersTyping(typingUsers.length) + : context.l10n.userIsTyping(typingUsers.first.displayName ?? ''), + style: context.textTheme.bodyMedium + ?.copyWith(color: context.colorScheme.primary), + maxLines: 1, + ); } } @@ -60,21 +60,42 @@ class _LastEvent extends StatefulWidget with WatchItStatefulWidgetMixin { class _LastEventState extends State<_LastEvent> { late final Future _future; + static final Map _cache = {}; + @override void initState() { super.initState(); - _future = widget.lastEvent - ?.calcLocalizedBody(const MatrixDefaultLocalizations()) ?? - Future.value(widget.lastEvent?.body ?? ''); + _future = widget.lastEvent != null && + _cache.containsKey(widget.lastEvent!.eventId) + ? Future.value(_cache[widget.lastEvent!.eventId]!) + : widget.lastEvent + ?.calcLocalizedBody(const MatrixDefaultLocalizations()) ?? + Future.value(widget.lastEvent?.body ?? ''); } @override Widget build(BuildContext context) { + if (widget.lastEvent != null && + _cache.containsKey(widget.lastEvent!.eventId)) { + return Text( + _cache[widget.lastEvent!.eventId]!, + maxLines: 1, + ); + } + return FutureBuilder( future: _future, builder: (context, snapshot) { + if (snapshot.hasData && widget.lastEvent != null) { + _cache[widget.lastEvent!.eventId] = snapshot.data!; + return Text( + snapshot.data!, + maxLines: 1, + ); + } + return Text( - snapshot.hasData ? snapshot.data! : (widget.fallbackText ?? ' '), + widget.fallbackText ?? ' ', maxLines: 1, ); }, diff --git a/lib/chat/view/chat_room/chat_room_page.dart b/lib/chat/view/chat_room/chat_room_page.dart index 55d3c38..e75e710 100644 --- a/lib/chat/view/chat_room/chat_room_page.dart +++ b/lib/chat/view/chat_room/chat_room_page.dart @@ -10,9 +10,10 @@ import '../../../common/view/snackbars.dart'; import '../../../common/view/ui_constants.dart'; import '../../../l10n/l10n.dart'; import '../../chat_model.dart'; +import '../../timeline_model.dart'; import 'chat_room_default_background.dart'; import 'chat_room_info_drawer.dart'; -import 'chat_timeline_list.dart'; +import 'chat_room_timeline_list.dart'; import 'input/chat_input.dart'; import 'titlebar/chat_room_title_bar.dart'; @@ -50,7 +51,8 @@ class _ChatRoomPageState extends State { final archiveActive = watchPropertyValue((ChatModel m) => m.archiveActive); final loadingArchive = watchPropertyValue((ChatModel m) => m.loadingArchive); - final updating = watchPropertyValue((ChatModel m) => m.updatingTimeline); + final updating = + watchPropertyValue((TimelineModel m) => m.updatingTimeline); registerStreamHandler( select: (ChatModel m) => m.getLeftRoomStream(widget.room.id), @@ -103,7 +105,7 @@ class _ChatRoomPageState extends State { return Padding( padding: const EdgeInsets.only(bottom: kMediumPadding), - child: ChatTimelineList( + child: ChatRoomTimelineList( timeline: snapshot.data!, room: widget.room, listKey: _roomListKey, @@ -112,22 +114,27 @@ class _ChatRoomPageState extends State { }, ), ), - if (updating) - Positioned( - top: 3 * kBigPadding, - child: FloatingActionButton.small( - backgroundColor: context.colorScheme.isLight - ? Colors.white - : Colors.black.scale(lightness: 0.09), - onPressed: () {}, - child: const SizedBox.square( - dimension: 20, - child: Progress( - strokeWidth: 2, + Positioned( + top: 3 * kBigPadding, + child: IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: updating ? 1 : 0, + child: FloatingActionButton.small( + backgroundColor: context.colorScheme.isLight + ? Colors.white + : Colors.black.scale(lightness: 0.09), + onPressed: () {}, + child: const SizedBox.square( + dimension: 20, + child: Progress( + strokeWidth: 2, + ), ), ), ), ), + ), ], ); } diff --git a/lib/chat/view/chat_room/chat_timeline_list.dart b/lib/chat/view/chat_room/chat_room_timeline_list.dart similarity index 77% rename from lib/chat/view/chat_room/chat_timeline_list.dart rename to lib/chat/view/chat_room/chat_room_timeline_list.dart index a462d9a..6092717 100644 --- a/lib/chat/view/chat_room/chat_timeline_list.dart +++ b/lib/chat/view/chat_room/chat_room_timeline_list.dart @@ -7,13 +7,14 @@ import 'package:yaru/yaru.dart'; import '../../../common/view/build_context_x.dart'; import '../../../common/view/theme.dart'; import '../../../common/view/ui_constants.dart'; -import '../../chat_model.dart'; +import '../../timeline_model.dart'; import '../events/chat_event_column.dart'; import 'chat_typing_indicator.dart'; import 'titlebar/chat_room_title_bar.dart'; -class ChatTimelineList extends StatefulWidget with WatchItStatefulWidgetMixin { - const ChatTimelineList({ +class ChatRoomTimelineList extends StatefulWidget + with WatchItStatefulWidgetMixin { + const ChatRoomTimelineList({ super.key, required this.timeline, required this.listKey, @@ -25,11 +26,11 @@ class ChatTimelineList extends StatefulWidget with WatchItStatefulWidgetMixin { final GlobalKey listKey; @override - State createState() => _ChatTimelineListState(); + State createState() => _ChatRoomTimelineListState(); } -class _ChatTimelineListState extends State { - late AutoScrollController _controller; +class _ChatRoomTimelineListState extends State { + final AutoScrollController _controller = AutoScrollController(); bool _showScrollButton = false; int retryCount = 15; @@ -37,22 +38,8 @@ class _ChatTimelineListState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( - (_) { - di() - .requestHistory( - widget.timeline, - historyCount: 350, - ) - .then( - (value) { - if (widget.room.membership == Membership.join) { - widget.timeline.setReadMarker(); - } - }, - ); - }, + (_) => di().requestHistory(widget.timeline), ); - _controller = AutoScrollController(); } @override @@ -71,23 +58,7 @@ class _ChatTimelineListState extends State { children: [ Expanded( child: NotificationListener( - onNotification: (scrollEnd) { - final metrics = scrollEnd.metrics; - if (metrics.atEdge) { - final isAtBottom = metrics.pixels != 0; - if (isAtBottom) { - di().requestHistory( - widget.timeline, - historyCount: 150, - ); - } else { - setState(() => _showScrollButton = false); - } - } else { - setState(() => _showScrollButton = true); - } - return true; - }, + onNotification: onScroll, child: AnimatedList( controller: _controller, padding: const EdgeInsets.symmetric( @@ -110,8 +81,8 @@ class _ChatTimelineListState extends State { index: i, controller: _controller, key: ValueKey('${event.eventId}tag'), - child: SizeTransition( - sizeFactor: animation, + child: FadeTransition( + opacity: animation, child: ChatEventColumn( key: ValueKey('${event.eventId}column'), event: event, @@ -177,10 +148,28 @@ class _ChatTimelineListState extends State { ); } + bool onScroll(scrollEnd) { + final metrics = scrollEnd.metrics; + if (metrics.atEdge) { + final isAtBottom = metrics.pixels != 0; + if (isAtBottom) { + di().requestHistory( + widget.timeline, + historyCount: 150, + ); + } else { + setState(() => _showScrollButton = false); + } + } else { + setState(() => _showScrollButton = true); + } + return true; + } + Future _jump(Event event) async { int index = widget.timeline.events.indexOf(event); while (index == -1 && retryCount >= 0) { - await di().requestHistory( + await di().requestHistory( widget.timeline, historyCount: 5, ); diff --git a/lib/chat/view/chat_room/input/chat_input.dart b/lib/chat/view/chat_room/input/chat_input.dart index bc35d41..9e9bc04 100644 --- a/lib/chat/view/chat_room/input/chat_input.dart +++ b/lib/chat/view/chat_room/input/chat_input.dart @@ -34,17 +34,21 @@ class _ChatInputState extends State { _sendController = TextEditingController(text: di().getDraft(widget.room.id)); _sendNode = FocusNode( - onKeyEvent: (node, KeyEvent evt) { - if (HardwareKeyboard.instance.isShiftPressed && - evt.logicalKey.keyLabel == 'Enter') { - if (evt is KeyDownEvent) { - _sendController.clear(); - _sendNode.requestFocus(); - di().send( - room: widget.room, - onFail: (error) => showSnackBar(context, content: Text(error)), + onKeyEvent: (node, event) { + final enterPressedWithoutShift = event is KeyDownEvent && + event.physicalKey == PhysicalKeyboardKey.enter && + !HardwareKeyboard.instance.physicalKeysPressed.any( + (key) => { + PhysicalKeyboardKey.shiftLeft, + PhysicalKeyboardKey.shiftRight, + }.contains(key), ); - } + + if (enterPressedWithoutShift) { + send(); + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + // Disable holding enter return KeyEventResult.handled; } else { return KeyEventResult.ignored; @@ -60,13 +64,25 @@ class _ChatInputState extends State { super.dispose(); } + Future send() async { + final model = di(); + if (model.sending) { + return; + } + _sendController.clear(); + _sendNode.requestFocus(); + await model.send( + room: widget.room, + onFail: (error) => showSnackBar(context, content: Text(error)), + ); + } + @override Widget build(BuildContext context) { final draftModel = di(); final draftFiles = watchPropertyValue((DraftModel m) => m.getFilesDraft(widget.room.id)); final attaching = watchPropertyValue((DraftModel m) => m.attaching); - final sending = watchPropertyValue((DraftModel m) => m.sending); final replyEvent = watchPropertyValue((DraftModel m) => m.replyEvent); final editEvent = @@ -77,16 +93,6 @@ class _ChatInputState extends State { _sendController.text = draft ?? ''; _sendNode.requestFocus(); - var onPressed = sending - ? null - : () async { - _sendController.clear(); - _sendNode.requestFocus(); - await draftModel.send( - room: widget.room, - onFail: (error) => showSnackBar(context, content: Text(error)), - ); - }; var transform = Transform.rotate( angle: pi / 4, child: const Padding( @@ -155,7 +161,7 @@ class _ChatInputState extends State { ); widget.room.setTyping(v.isNotEmpty, timeout: 500); }, - onSubmitted: (_) => onPressed?.call(), + // onSubmitted: (_) => send.call(), decoration: InputDecoration( hintText: context.l10n.sendAMessage, prefixIcon: Padding( @@ -202,7 +208,7 @@ class _ChatInputState extends State { IconButton( padding: EdgeInsets.zero, icon: transform, - onPressed: onPressed, + onPressed: send, ), ], ), diff --git a/lib/chat/view/events/chat_html_message.dart b/lib/chat/view/events/chat_html_message.dart index 95bfa20..2faf973 100644 --- a/lib/chat/view/events/chat_html_message.dart +++ b/lib/chat/view/events/chat_html_message.dart @@ -15,6 +15,9 @@ import '../../../common/view/confirm.dart'; import '../../../l10n/l10n.dart'; import '../mxc_image.dart'; +// Credit: this code has been copied and from https://github.com/krille-chan/fluffychat +// and then modified +// Thank you @krille-chan class HtmlMessage extends StatelessWidget { final String html; final Room room; @@ -36,7 +39,10 @@ class HtmlMessage extends StatelessWidget { return Html.fromElement( documentElement: element as dom.Element, - style: {}, + style: { + 'code': Style(padding: HtmlPaddings.zero, margin: Margins.zero), + 'pre': Style(padding: HtmlPaddings.zero, margin: Margins.zero), + }, extensions: [ CodeExtension(fontSize: fontSize, isLight: theme.colorScheme.isLight), SpoilerExtension(textColor: defaultTextColor), @@ -245,7 +251,7 @@ class CodeExtension extends HtmlExtension { language: context.element?.className .split(' ') .singleWhereOrNull( - (className) => className.startsWith('language-'), + (c) => c.startsWith('language-'), ) ?.split('language-') .last ?? @@ -253,7 +259,7 @@ class CodeExtension extends HtmlExtension { theme: isLight ? vsTheme : draculaTheme, textStyle: TextStyle( fontSize: fontSize, - // fontFamily: 'UbuntuMono', + fontFamily: 'UbuntuMono', ), ), ), @@ -322,6 +328,5 @@ const Set _allowedHtmlTags = { 'ruby', 'rp', 'rt', - // Workaround for https://github.com/krille-chan/fluffychat/issues/507 ..._fallbackTextTags, }; diff --git a/lib/register.dart b/lib/register.dart index d5e1eee..d1b399f 100644 --- a/lib/register.dart +++ b/lib/register.dart @@ -24,6 +24,7 @@ import 'chat/remote_image_model.dart'; import 'chat/remote_image_service.dart'; import 'chat/search_model.dart'; import 'chat/settings/settings_model.dart'; +import 'chat/timeline_model.dart'; void registerDependencies() => di ..registerSingletonAsync( @@ -116,6 +117,10 @@ void registerDependencies() => di () => SearchModel(client: di()), dispose: (s) => s.dispose(), dependsOn: [Client], + ) + ..registerLazySingleton( + () => TimelineModel(), + dispose: (s) => s.dispose(), ); extension _ClientX on Client {