From b9cb6a8af1076088b3c8f202279756b62ef00f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 28 Oct 2026 22:40:21 +0100 Subject: [PATCH] chat search now lists local and server-side hits like the file search, and jumping to old messages pages in history with a progress banner and keeps the hit marked --- .../search/search_chat_messages.dart | 37 +++ .../search/search_chat_messages_response.dart | 94 ++++++ .../app/modules/chat/bloc/chat_bloc.dart | 42 +++ lib/view/pages/talk/chat_view.dart | 275 ++++++++---------- .../pages/talk/data/chat_message_matcher.dart | 95 ++++++ .../talk/data/chat_search_controller.dart | 56 ---- .../talk/search/chat_search_controller.dart | 129 ++++++++ .../talk/search/chat_search_delegate.dart | 49 ++++ .../talk/search/chat_search_results.dart | 197 +++++++++++++ lib/view/pages/talk/widgets/chat_bubble.dart | 9 +- .../talk/widgets/chat_search_app_bar.dart | 70 ----- .../talk/widgets/history_jump_banner.dart | 77 +++++ ...st.dart => chat_message_matcher_test.dart} | 118 ++++++-- 13 files changed, 926 insertions(+), 322 deletions(-) create mode 100644 lib/api/marianumcloud/search/search_chat_messages.dart create mode 100644 lib/api/marianumcloud/search/search_chat_messages_response.dart create mode 100644 lib/view/pages/talk/data/chat_message_matcher.dart delete mode 100644 lib/view/pages/talk/data/chat_search_controller.dart create mode 100644 lib/view/pages/talk/search/chat_search_controller.dart create mode 100644 lib/view/pages/talk/search/chat_search_delegate.dart create mode 100644 lib/view/pages/talk/search/chat_search_results.dart delete mode 100644 lib/view/pages/talk/widgets/chat_search_app_bar.dart create mode 100644 lib/view/pages/talk/widgets/history_jump_banner.dart rename test/view/talk/{chat_search_controller_test.dart => chat_message_matcher_test.dart} (56%) diff --git a/lib/api/marianumcloud/search/search_chat_messages.dart b/lib/api/marianumcloud/search/search_chat_messages.dart new file mode 100644 index 0000000..811e0d8 --- /dev/null +++ b/lib/api/marianumcloud/search/search_chat_messages.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../nextcloud_ocs.dart'; +import 'search_chat_messages_response.dart'; + +/// Wraps Talk's `talk-message-current` search provider, which searches the +/// whole server-side history of one conversation. The conversation is picked +/// via `from`, the route the provider would be opened from in the web UI. +class SearchChatMessages { + Future run({ + required String chatToken, + required String term, + int limit = 25, + Object? cursor, + }) async { + final endpoint = NextcloudOcs.uri( + 'search/providers/talk-message-current/search', + queryParameters: { + 'term': term, + 'from': '/call/$chatToken', + 'limit': limit.toString(), + if (cursor != null) 'cursor': cursor.toString(), + }, + ); + final response = await http.get(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Chat search failed with ${response.statusCode}: ${response.body}', + ); + } + final data = + NextcloudOcs.decode(response.body)['data'] as Map; + return SearchChatMessagesResponse.fromJson(data, term: term); + } +} diff --git a/lib/api/marianumcloud/search/search_chat_messages_response.dart b/lib/api/marianumcloud/search/search_chat_messages_response.dart new file mode 100644 index 0000000..c8c1d45 --- /dev/null +++ b/lib/api/marianumcloud/search/search_chat_messages_response.dart @@ -0,0 +1,94 @@ +/// Parsed page of Talk's `talk-message-current` search provider. +class SearchChatMessagesResponse { + final List entries; + + /// Opaque pagination cursor; `null` when the provider has no further page. + final Object? cursor; + + const SearchChatMessagesResponse({required this.entries, this.cursor}); + + factory SearchChatMessagesResponse.fromJson( + Map json, { + required String term, + }) { + final rawEntries = json['entries']; + final entries = rawEntries is List + ? rawEntries + .whereType>() + .map((e) => SearchChatMessagesEntry.fromJson(e, term: term)) + .whereType() + .toList() + : const []; + final paginated = json['isPaginated'] == true; + final cursor = json['cursor']; + return SearchChatMessagesResponse( + entries: entries, + cursor: paginated && entries.isNotEmpty && cursor != null ? cursor : null, + ); + } +} + +class SearchChatMessagesEntry { + final int messageId; + final String? actorId; + final String? actorDisplayName; + + /// Server-built excerpt of the message, placeholders already resolved. + final String excerpt; + final DateTime? sentAt; + + const SearchChatMessagesEntry({ + required this.messageId, + required this.excerpt, + this.actorId, + this.actorDisplayName, + this.sentAt, + }); + + /// Returns `null` for entries without a recognisable message id. + static SearchChatMessagesEntry? fromJson( + Map json, { + required String term, + }) { + final attributes = json['attributes']; + final attrs = attributes is Map ? attributes : const {}; + final messageId = + _parseInt(attrs['messageId']) ?? + _parseInt( + RegExp( + r'#message_(\d+)', + ).firstMatch(json['resourceUrl']?.toString() ?? '')?.group(1), + ); + if (messageId == null) return null; + + // Title and subline have swapped roles across Talk versions (author vs. + // excerpt); the one containing the search term is the excerpt. + final title = json['title']?.toString() ?? ''; + final subline = json['subline']?.toString() ?? ''; + final q = term.trim().toLowerCase(); + final titleIsExcerpt = + q.isNotEmpty && + title.toLowerCase().contains(q) && + !subline.toLowerCase().contains(q); + final excerpt = titleIsExcerpt ? title : subline; + final author = titleIsExcerpt ? subline : title; + + final timestamp = _parseInt(attrs['timestamp']); + final actorId = attrs['actorId']?.toString(); + return SearchChatMessagesEntry( + messageId: messageId, + excerpt: excerpt, + actorId: actorId == null || actorId.isEmpty ? null : actorId, + actorDisplayName: author.isEmpty ? null : author, + sentAt: timestamp == null + ? null + : DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + ); + } + + static int? _parseInt(Object? raw) => switch (raw) { + final int v => v, + final String v => int.tryParse(v), + _ => null, + }; +} diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index e2b9a1a..d809012 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -180,6 +180,48 @@ class ChatBloc } } + /// Pages in older history until [messageId] is held. Returns false when the + /// history ends (or a page brings no progress) before reaching it, or when + /// [isCancelled] or a chat switch aborts the walk. [onProgress] reports the + /// send time of the oldest held message after every page. + Future loadOlderUntil( + int messageId, { + required bool Function() isCancelled, + ValueChanged? onProgress, + }) async { + final token = innerState?.currentToken ?? ''; + while (true) { + final state = innerState; + final response = state?.chatResponse; + if (isClosed || isCancelled() || state == null || response == null) { + return false; + } + if (state.currentToken != token) return false; + if (response.data.any((m) => m.id == messageId)) return true; + final oldestId = _minMessageId(response); + if (oldestId > 0 && oldestId < messageId) return false; + if (!state.hasMoreOld) return false; + + await loadOlder(); + // A load started by the scroll listener makes loadOlder() a no-op, and + // its result is merged by a queued event — wait until it has landed. + if (innerState?.isLoadingOlder ?? false) { + try { + await stream.firstWhere((s) => !(s.data?.isLoadingOlder ?? false)); + } on StateError { + return false; // bloc closed meanwhile + } + } + final after = innerState?.chatResponse; + if (after == null || _minMessageId(after) == oldestId) { + if (innerState?.hasMoreOld ?? false) return false; + } else { + final oldest = after.data.where((m) => m.id == _minMessageId(after)); + if (oldest.isNotEmpty) onProgress?.call(oldest.first.sentAt); + } + } + } + Future sendServerReadMarker(String token, int messageId) async { try { await SetReadMarker( diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index ea16c47..8ecbb79 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -14,16 +14,17 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; -import '../../../utils/debouncer.dart'; import '../../../widget/chat_background.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; -import 'data/chat_search_controller.dart'; +import 'data/chat_message_matcher.dart'; import 'details/chat_info.dart'; +import 'search/chat_search_controller.dart'; +import 'search/chat_search_delegate.dart'; import 'talk_navigator.dart'; import 'widgets/chat_bubble.dart'; -import 'widgets/chat_search_app_bar.dart'; import 'widgets/chat_textfield.dart'; +import 'widgets/history_jump_banner.dart'; class ChatView extends StatefulWidget { final GetRoomResponseObject room; @@ -45,19 +46,21 @@ class _ChatViewState extends State with RouteAware { final ItemScrollController _itemScrollController = ItemScrollController(); final ItemPositionsListener _positionsListener = ItemPositionsListener.create(); - final TextEditingController _searchTextController = TextEditingController(); - final Map _matchIndices = {}; // Number of rows currently rendered; kept in sync in build so the scroll // listener can tell when the oldest row (highest index, reverse list) nears. int _itemCount = 0; - bool _searchActive = false; - String _searchQuery = ''; - List _matches = const []; - int _activeMatchIndex = 0; - GetChatResponse? _matchesComputedFor; - String? _matchesComputedQuery; + // Search hit the chat jumped to; stays marked until the next search. + int? _highlightId; + String? _highlightQuery; + int? _highlightIndex; + + // Running jump towards a search hit that is not loaded yet. + ChatSearchHit? _jumpTarget; + DateTime _jumpStartedAt = DateTime.now(); + DateTime? _jumpLoadedUntil; + int _jumpEpoch = 0; // Captured in initState because the framework has unmounted us by the // time dispose runs. @@ -116,8 +119,6 @@ class _ChatViewState extends State with RouteAware { _positionsListener.itemPositions.removeListener(_onScrollPositions); _markAsReadFinal(); _chatBloc.leaveChat(widget.room.token); - _searchTextController.dispose(); - Debouncer.cancel(_searchDebounceTag); super.dispose(); } @@ -141,9 +142,7 @@ class _ChatViewState extends State with RouteAware { @override void didUpdateWidget(covariant ChatView oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.room.token != oldWidget.room.token && _searchActive) { - _exitSearchMode(); - } + if (widget.room.token != oldWidget.room.token) _clearHighlight(); } void _refetch() => _chatBloc.setToken(widget.room.token); @@ -161,9 +160,8 @@ class _ChatViewState extends State with RouteAware { List _itemsFor(ChatState state) { final messagesKey = ( state.chatResponse, - _searchActive, - _searchQuery, - _activeMatchIndex, + _highlightId, + _highlightQuery, widget.room, ); if (messagesKey != _messagesKey) { @@ -192,117 +190,93 @@ class _ChatViewState extends State with RouteAware { return _items = items; } - void _enterSearchMode() => _resetSearch(active: true); - - void _exitSearchMode() { - Debouncer.cancel(_searchDebounceTag); - _matchIndices.clear(); - _resetSearch(active: false); + Future _openSearch() async { + final delegate = ChatSearchDelegate( + ChatSearchController( + chatToken: widget.room.token, + loadedMessages: () { + final state = _chatBloc.state.data; + return state?.currentToken == widget.room.token + ? state?.chatResponse + : null; + }, + ), + ); + final hit = await showSearch( + context: context, + delegate: delegate, + ); + final query = delegate.query; + delegate.disposeController(); + if (hit == null || !mounted) return; + await _jumpToMessage(hit, query); } - void _resetSearch({required bool active}) { + /// Older hits from the server search are usually not loaded yet; the + /// history is paged in until the message is reached. + Future _jumpToMessage(ChatSearchHit hit, String query) async { + final epoch = ++_jumpEpoch; + _clearHighlight(); setState(() { - _searchActive = active; - _searchQuery = ''; - _matches = const []; - _activeMatchIndex = 0; - _matchesComputedFor = null; - _matchesComputedQuery = null; - _searchTextController.clear(); + _jumpTarget = hit; + _jumpStartedAt = DateTime.now(); + _jumpLoadedUntil = null; }); - } - - late final String _searchDebounceTag = - 'chat-search-${identityHashCode(this)}'; - - /// Matching re-scans the whole history and rebuilds every row, so it runs - /// once typing pauses instead of per keystroke. - void _onSearchChanged(String q) { - Debouncer.debounce( - _searchDebounceTag, - const Duration(milliseconds: 200), - () { - if (mounted) _applySearch(q); + final found = await _chatBloc.loadOlderUntil( + hit.messageId, + isCancelled: () => !mounted || epoch != _jumpEpoch, + onProgress: (oldest) { + if (mounted && epoch == _jumpEpoch) { + setState(() => _jumpLoadedUntil = oldest); + } }, ); - } - - void _applySearch(String q) { - final chatResponse = _chatBloc.state.data?.chatResponse; + if (!mounted || epoch != _jumpEpoch) return; setState(() { - _searchQuery = q; - _activeMatchIndex = 0; - if (chatResponse != null) { - _recomputeMatches(chatResponse); - } else { - _matches = const []; - _matchesComputedFor = null; - _matchesComputedQuery = null; + _jumpTarget = null; + if (found) { + _highlightId = hit.messageId; + _highlightQuery = query; } }); + if (!found) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nachricht nicht gefunden.')), + ); + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _matches.isNotEmpty) _scrollToActiveMatch(); + final idx = _highlightIndex; + if (!mounted || idx == null || !_itemScrollController.isAttached) return; + _itemScrollController.scrollTo( + index: idx, + alignment: 0.4, + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); }); } - void _recomputeMatches(GetChatResponse response) { - _matches = ChatSearchController.findMatches(response, _searchQuery); - _activeMatchIndex = _activeMatchIndex.clamp( - 0, - math.max(0, _matches.length - 1), - ); - _matchesComputedFor = response; - _matchesComputedQuery = _searchQuery; + void _cancelJump() { + _jumpEpoch++; + setState(() => _jumpTarget = null); } - // Matches are newest-first, so "previous" (older) steps forward. - void _stepMatch(int delta) { - if (_matches.isEmpty) return; - setState(() { - _activeMatchIndex = - (_activeMatchIndex + delta + _matches.length) % _matches.length; - }); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch()); - } - - void _scrollToActiveMatch() { - if (_matches.isEmpty) return; - if (!_itemScrollController.isAttached) return; - final id = _matches[_activeMatchIndex].messageId; - final idx = _matchIndices[id]; - if (idx == null) return; - _itemScrollController.scrollTo( - index: idx, - alignment: 0.4, - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); + void _clearHighlight() { + _highlightId = null; + _highlightQuery = null; } List _buildMessages(GetChatResponse response) { - if (_searchActive && - (response != _matchesComputedFor || - _searchQuery != _matchesComputedQuery)) { - _recomputeMatches(response); - } - - final matchIds = _matches.map((m) => m.messageId).toSet(); - final activeId = _matches.isNotEmpty - ? _matches[_activeMatchIndex].messageId - : null; - final highlightQuery = _searchActive && _searchQuery.trim().isNotEmpty - ? _searchQuery - : null; - final commonRead = int.parse( response.headers?['x-chat-last-common-read'] ?? '0', ); final messages = []; - final chronologicalMatchIndex = {}; + int? highlightPosition; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { - if (ChatSearchController.isHiddenSystemMessage(element)) continue; + if (ChatMessageMatcher.isHiddenSystemMessage(element)) continue; final elementDate = element.sentAt; @@ -322,14 +296,8 @@ class _ChatViewState extends State with RouteAware { ); } - final isMatch = matchIds.contains(element.id); - final highlight = isMatch - ? (element.id == activeId - ? SearchHighlight.active - : SearchHighlight.secondary) - : SearchHighlight.none; - - if (isMatch) chronologicalMatchIndex[element.id] = messages.length; + final isHighlighted = element.id == _highlightId; + if (isHighlighted) highlightPosition = messages.length; messages.add( ChatBubble( @@ -345,20 +313,18 @@ class _ChatViewState extends State with RouteAware { refetch: _refetch, isRead: element.id <= commonRead, selfId: widget.selfId, - highlightQuery: highlightQuery, - matchHighlight: highlight, + highlightQuery: isHighlighted ? _highlightQuery : null, + matchHighlight: isHighlighted + ? SearchHighlight.active + : SearchHighlight.none, ), ); } - final total = messages.length; - _matchIndices - ..clear() - ..addEntries( - chronologicalMatchIndex.entries.map( - (e) => MapEntry(e.key, (total - 1) - e.value), - ), - ); + // The list is rendered reversed (newest at index 0). + _highlightIndex = highlightPosition == null + ? null + : messages.length - 1 - highlightPosition; return messages; } @@ -373,45 +339,42 @@ class _ChatViewState extends State with RouteAware { return _KeyboardDismissPopScope( child: Scaffold( backgroundColor: const Color(0xffefeae2), - appBar: _searchActive - ? ChatSearchAppBar( - controller: _searchTextController, - matchCount: _matches.length, - activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex, - onChanged: _onSearchChanged, - onClose: _exitSearchMode, - onPrevious: _matches.isEmpty ? null : () => _stepMatch(1), - onNext: _matches.isEmpty ? null : () => _stepMatch(-1), - ) - : ClickableAppBar( - onTap: () => - TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), - appBar: AppBar( - title: Row( - children: [ - widget.avatar, - const SizedBox(width: 10), - Expanded( - child: Text( - widget.room.displayName, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - ], + appBar: ClickableAppBar( + onTap: () => + TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), + appBar: AppBar( + title: Row( + children: [ + widget.avatar, + const SizedBox(width: 10), + Expanded( + child: Text( + widget.room.displayName, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - actions: [ - IconButton( - icon: const Icon(Icons.search), - tooltip: 'In Chat suchen', - onPressed: _enterSearchMode, - ), - ], ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'In Chat suchen', + onPressed: _openSearch, ), + ], + ), + ), body: ChatBackground( child: Column( children: [ + if (_jumpTarget case final target?) + HistoryJumpBanner( + startedAt: _jumpStartedAt, + target: target.sentAt, + loadedUntil: _jumpLoadedUntil, + onCancel: _cancelJump, + ), Expanded( child: LoadableStateConsumer( isReady: (state) => diff --git a/lib/view/pages/talk/data/chat_message_matcher.dart b/lib/view/pages/talk/data/chat_message_matcher.dart new file mode 100644 index 0000000..cdb3c62 --- /dev/null +++ b/lib/view/pages/talk/data/chat_message_matcher.dart @@ -0,0 +1,95 @@ +import '../../../../api/marianumcloud/search/search_chat_messages_response.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; + +/// One message matching a chat search, either from the loaded history or +/// from the server-side search. +class ChatSearchHit { + final int messageId; + final String? actorId; + final String? actorDisplayName; + final String text; + final DateTime? sentAt; + final bool isSystem; + + const ChatSearchHit({ + required this.messageId, + required this.text, + this.actorId, + this.actorDisplayName, + this.sentAt, + this.isSystem = false, + }); + + factory ChatSearchHit.fromServer(SearchChatMessagesEntry entry) => + ChatSearchHit( + messageId: entry.messageId, + text: entry.excerpt, + actorId: entry.actorId, + actorDisplayName: entry.actorDisplayName, + sentAt: entry.sentAt, + ); +} + +class ChatMessageMatcher { + /// System messages that are folded into other bubbles (reactions, poll + /// votes, deletions) and therefore never rendered nor searched as their own + /// entry. + static bool isHiddenSystemMessage(GetChatResponseObject element) => + element.systemMessage.contains('reaction') || + element.systemMessage.contains('poll_voted') || + element.systemMessage.contains('message_deleted'); + + /// Newest first. + static List findMatches( + GetChatResponse response, + String query, + ) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return const []; + + final matches = []; + for (final element in response.data) { + if (element.id <= 0 || isHiddenSystemMessage(element)) continue; + + final text = RichObjectStringProcessor.parseToString( + element.message, + element.messageParameters, + ); + final isSystem = + element.messageType == GetRoomResponseObjectMessageType.system; + + final matched = + text.toLowerCase().contains(q) || + (!isSystem && element.actorDisplayName.toLowerCase().contains(q)); + if (!matched) continue; + + matches.add( + ChatSearchHit( + messageId: element.id, + text: text, + actorId: isSystem ? null : element.actorId, + actorDisplayName: isSystem ? null : element.actorDisplayName, + sentAt: element.sentAt, + isSystem: isSystem, + ), + ); + } + + matches.sort((a, b) => b.sentAt!.compareTo(a.sentAt!)); + return matches; + } + + /// Local hits win over server hits for the same message — they carry the + /// full text and exact metadata. Result is newest first. + static List merge( + List local, + List server, + ) { + if (server.isEmpty) return local; + final seen = {for (final h in local) h.messageId}; + return [...local, ...server.where((h) => seen.add(h.messageId))] + ..sort((a, b) => b.messageId.compareTo(a.messageId)); + } +} diff --git a/lib/view/pages/talk/data/chat_search_controller.dart b/lib/view/pages/talk/data/chat_search_controller.dart deleted file mode 100644 index 7e53a78..0000000 --- a/lib/view/pages/talk/data/chat_search_controller.dart +++ /dev/null @@ -1,56 +0,0 @@ -import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; -import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; -import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; - -class ChatSearchMatch { - final int messageId; - final int timestamp; - - const ChatSearchMatch({required this.messageId, required this.timestamp}); -} - -class ChatSearchController { - /// System messages that are folded into other bubbles (reactions, poll - /// votes, deletions) and therefore never rendered nor searched as their own - /// entry. - static bool isHiddenSystemMessage(GetChatResponseObject element) => - element.systemMessage.contains('reaction') || - element.systemMessage.contains('poll_voted') || - element.systemMessage.contains('message_deleted'); - - static List findMatches( - GetChatResponse response, - String query, - ) { - final q = query.trim().toLowerCase(); - if (q.isEmpty) return const []; - - final matches = []; - for (final element in response.sortByTimestamp()) { - if (isHiddenSystemMessage(element)) continue; - - final haystackText = RichObjectStringProcessor.parseToString( - element.message, - element.messageParameters, - ).toLowerCase(); - - var matched = haystackText.contains(q); - if (!matched && - element.messageType != GetRoomResponseObjectMessageType.system) { - matched = element.actorDisplayName.toLowerCase().contains(q); - } - - if (matched) { - matches.add( - ChatSearchMatch( - messageId: element.id, - timestamp: element.timestamp, - ), - ); - } - } - - matches.sort((a, b) => b.timestamp.compareTo(a.timestamp)); - return matches; - } -} diff --git a/lib/view/pages/talk/search/chat_search_controller.dart b/lib/view/pages/talk/search/chat_search_controller.dart new file mode 100644 index 0000000..39fb8fb --- /dev/null +++ b/lib/view/pages/talk/search/chat_search_controller.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; + +import '../../../../api/demo/demo_mode.dart'; +import '../../../../api/marianumcloud/search/search_chat_messages.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../utils/debouncer.dart'; +import '../data/chat_message_matcher.dart'; + +/// Live state of a search inside one chat, mirroring `FilesSearchController`: +/// hits from the loaded history appear immediately, the server-side search +/// over the full history follows debounced and can be paged further. +class ChatSearchController extends ChangeNotifier { + ChatSearchController({ + required this.chatToken, + required GetChatResponse? Function() loadedMessages, + }) : _loadedMessages = loadedMessages; + + final String chatToken; + final GetChatResponse? Function() _loadedMessages; + + static const Duration _serverDebounce = Duration(seconds: 1); + final String _debounceTag = + 'chat-search-${DateTime.now().microsecondsSinceEpoch}'; + final SearchChatMessages _api = SearchChatMessages(); + + String _query = ''; + List _localResults = const []; + List _serverResults = const []; + Object? _serverCursor; + bool _serverLoading = false; + bool _loadingMore = false; + Object? _serverError; + int _serverEpoch = 0; + bool _disposed = false; + + void _safeNotify() { + if (_disposed) return; + notifyListeners(); + } + + String get query => _query; + bool get serverLoading => _serverLoading; + bool get loadingMore => _loadingMore; + bool get canLoadMore => _serverCursor != null; + Object? get serverError => _serverError; + + List get combinedResults => + ChatMessageMatcher.merge(_localResults, _serverResults); + + void setQuery(String value) { + if (value == _query) return; + _query = value; + // Invalidates in-flight server calls of the previous query. + ++_serverEpoch; + _serverResults = const []; + _serverCursor = null; + _loadingMore = false; + _serverError = null; + if (_query.trim().isEmpty) { + Debouncer.cancel(_debounceTag); + _localResults = const []; + _serverLoading = false; + _safeNotify(); + return; + } + final loaded = _loadedMessages(); + _localResults = loaded == null + ? const [] + : ChatMessageMatcher.findMatches(loaded, _query); + // Demo sessions must not hit the real Nextcloud (see DemoHttpClient). + _serverLoading = !DemoMode.active; + _safeNotify(); + if (_serverLoading) { + Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall); + } + } + + void retry() { + if (_query.trim().isEmpty || DemoMode.active) return; + ++_serverEpoch; + Debouncer.cancel(_debounceTag); + _serverResults = const []; + _serverCursor = null; + _loadingMore = false; + _serverLoading = true; + _serverError = null; + _safeNotify(); + _runServerCall(); + } + + void loadMore() { + if (_serverCursor == null || _serverLoading || _loadingMore) return; + _loadingMore = true; + _safeNotify(); + _runServerCall(cursor: _serverCursor); + } + + Future _runServerCall({Object? cursor}) async { + final epoch = _serverEpoch; + try { + final response = await _api.run( + chatToken: chatToken, + term: _query.trim(), + cursor: cursor, + ); + if (epoch != _serverEpoch) return; + _serverResults = [ + ..._serverResults, + ...response.entries.map(ChatSearchHit.fromServer), + ]; + _serverCursor = response.cursor; + _serverError = null; + } on Object catch (e) { + if (epoch != _serverEpoch) return; + _serverCursor = null; + _serverError = e; + } + _serverLoading = false; + _loadingMore = false; + _safeNotify(); + } + + @override + void dispose() { + _disposed = true; + Debouncer.cancel(_debounceTag); + super.dispose(); + } +} diff --git a/lib/view/pages/talk/search/chat_search_delegate.dart b/lib/view/pages/talk/search/chat_search_delegate.dart new file mode 100644 index 0000000..a57e3c8 --- /dev/null +++ b/lib/view/pages/talk/search/chat_search_delegate.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../data/chat_message_matcher.dart'; +import 'chat_search_controller.dart'; +import 'chat_search_results.dart'; + +/// Search inside one chat, built like `FilesSearchDelegate`. Closes with the +/// tapped hit so the chat can jump there. +class ChatSearchDelegate extends SearchDelegate { + final ChatSearchController _controller; + + ChatSearchDelegate(this._controller) + : super(searchFieldLabel: 'In Chat suchen'); + + /// Must be called by the host widget after `showSearch` returns so the + /// controller's listeners and pending debounce timers are released. + void disposeController() => _controller.dispose(); + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton( + tooltip: 'Suche leeren', + icon: const Icon(Icons.clear), + onPressed: () { + query = ''; + }, + ), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Zurück', + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) => buildSuggestions(context); + + @override + Widget buildSuggestions(BuildContext context) { + _controller.setQuery(query); + return ChatSearchResults( + controller: _controller, + onResultTap: (hit) => close(context, hit), + ); + } +} diff --git a/lib/view/pages/talk/search/chat_search_results.dart b/lib/view/pages/talk/search/chat_search_results.dart new file mode 100644 index 0000000..0769082 --- /dev/null +++ b/lib/view/pages/talk/search/chat_search_results.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/errors/error_mapper.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart'; +import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart'; +import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../../widget/placeholder_view.dart'; +import '../../../../widget/user_avatar.dart'; +import '../data/chat_message_matcher.dart'; +import '../widgets/highlighted_linkify.dart'; +import 'chat_search_controller.dart'; + +/// Renders a [ChatSearchController] with the same loading / error views as +/// `FilesSearchResults`. +class ChatSearchResults extends StatelessWidget { + final ChatSearchController controller; + final ValueChanged onResultTap; + + const ChatSearchResults({ + required this.controller, + required this.onResultTap, + super.key, + }); + + @override + Widget build(BuildContext context) => + BlocModule( + create: (_) => LoadableStateBloc(), + child: (context, bloc, _) { + bloc.reFetch = controller.retry; + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _buildBody(context), + ); + }, + ); + + Widget _buildBody(BuildContext context) { + if (controller.query.trim().isEmpty) { + return const PlaceholderView( + icon: Icons.search, + text: 'Tippen, um im Chat zu suchen.', + ); + } + final combined = controller.combinedResults; + final hasContent = combined.isNotEmpty; + final hasError = controller.serverError != null; + final isLoading = controller.serverLoading; + + final showPrimaryLoading = isLoading && !hasContent; + final showBackgroundLoading = isLoading && hasContent; + final showErrorScreen = hasError && !hasContent && !isLoading; + final showErrorBar = hasError && hasContent; + final showEmpty = !hasContent && !hasError && !isLoading; + + final errorMessage = hasError + ? errorToUserMessage(controller.serverError) + : null; + + return Column( + children: [ + LoadableStateErrorBar( + visible: showErrorBar, + hasContent: hasContent, + message: errorMessage, + ), + LoadableStateBackgroundLoading(visible: showBackgroundLoading), + Expanded( + child: Stack( + children: [ + LoadableStatePrimaryLoading(visible: showPrimaryLoading), + LoadableStateErrorScreen( + visible: showErrorScreen, + message: errorMessage, + ), + if (showEmpty) + const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer gefunden.', + ), + if (hasContent) _resultList(combined), + ], + ), + ), + ], + ); + } + + Widget _resultList(List hits) { + final showFooter = controller.canLoadMore || controller.loadingMore; + return NotificationListener( + onNotification: (n) { + if (n.metrics.extentAfter < 400) controller.loadMore(); + return false; + }, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: hits.length + (showFooter ? 1 : 0), + itemBuilder: (context, index) { + if (index == hits.length) { + // A short first page never scrolls, so the footer also triggers + // the next page when it gets built. + WidgetsBinding.instance.addPostFrameCallback( + (_) => controller.loadMore(), + ); + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final hit = hits[index]; + return _ChatSearchTile( + key: ValueKey(hit.messageId), + hit: hit, + query: controller.query, + onTap: () => onResultTap(hit), + ); + }, + ), + ); + } +} + +class _ChatSearchTile extends StatelessWidget { + final ChatSearchHit hit; + final String query; + final VoidCallback onTap; + + const _ChatSearchTile({ + required this.hit, + required this.query, + required this.onTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final base = + theme.textTheme.bodyMedium ?? DefaultTextStyle.of(context).style; + final actorId = hit.actorId; + final sentAt = hit.sentAt; + return ListTile( + leading: actorId != null + ? UserAvatar( + id: actorId, + size: 20, + semanticLabel: hit.actorDisplayName, + ) + : const CircleAvatar(radius: 20, child: Icon(Icons.info_outline)), + title: Row( + children: [ + Expanded( + child: Text.rich( + TextSpan( + children: buildHighlightedSpans( + text: hit.actorDisplayName ?? 'System', + query: query, + baseStyle: theme.textTheme.titleSmall ?? base, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (sentAt != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + sentAt.year == DateTime.now().year + ? sentAt.formatDateShortHm() + : sentAt.formatDate(), + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + subtitle: Text.rich( + TextSpan( + children: buildHighlightedSpans( + text: hit.text, + query: query, + baseStyle: base.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + onTap: onTap, + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index c153c42..492d1ae 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -23,7 +23,7 @@ import 'chat_bubble_reactions.dart'; import 'chat_message_options_dialog.dart'; import 'highlighted_linkify.dart'; -enum SearchHighlight { none, secondary, active } +enum SearchHighlight { none, active } const double _kSpacing = 3; const double _kTimeIconSize = 11; @@ -139,13 +139,6 @@ class _ChatBubbleState extends State switch (widget.matchHighlight) { case SearchHighlight.none: return base; - case SearchHighlight.secondary: - return base.copyWith( - borderWidth: 1.5, - borderColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.45), - ); case SearchHighlight.active: return base.copyWith( borderWidth: 3, diff --git a/lib/view/pages/talk/widgets/chat_search_app_bar.dart b/lib/view/pages/talk/widgets/chat_search_app_bar.dart deleted file mode 100644 index 02ffff8..0000000 --- a/lib/view/pages/talk/widgets/chat_search_app_bar.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; - -class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget { - final TextEditingController controller; - final int matchCount; - final int activeIndex; - final ValueChanged onChanged; - final VoidCallback onClose; - final VoidCallback? onPrevious; - final VoidCallback? onNext; - - const ChatSearchAppBar({ - required this.controller, - required this.matchCount, - required this.activeIndex, - required this.onChanged, - required this.onClose, - required this.onPrevious, - required this.onNext, - super.key, - }); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - Widget build(BuildContext context) { - final counterText = matchCount == 0 - ? '0/0' - : '${activeIndex + 1}/$matchCount'; - return AppBar( - leading: IconButton( - tooltip: 'Zurück', - icon: const Icon(Icons.arrow_back), - onPressed: onClose, - ), - title: TextField( - controller: controller, - autofocus: true, - textInputAction: TextInputAction.search, - decoration: const InputDecoration( - hintText: 'In Chat suchen…', - border: InputBorder.none, - ), - onChanged: onChanged, - ), - actions: [ - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - counterText, - style: const TextStyle(fontSize: 12), - ), - ), - ), - IconButton( - tooltip: 'Vorheriges Ergebnis', - icon: const Icon(Icons.keyboard_arrow_up), - onPressed: onPrevious, - ), - IconButton( - tooltip: 'Nächstes Ergebnis', - icon: const Icon(Icons.keyboard_arrow_down), - onPressed: onNext, - ), - ], - ); - } -} diff --git a/lib/view/pages/talk/widgets/history_jump_banner.dart b/lib/view/pages/talk/widgets/history_jump_banner.dart new file mode 100644 index 0000000..77e7584 --- /dev/null +++ b/lib/view/pages/talk/widgets/history_jump_banner.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../../../../extensions/date_time.dart'; + +/// Shown while the chat pages back through its history towards a search hit. +/// Progress is estimated by time, since the server reports no message counts. +class HistoryJumpBanner extends StatelessWidget { + final DateTime startedAt; + final DateTime? target; + final DateTime? loadedUntil; + final VoidCallback onCancel; + + const HistoryJumpBanner({ + required this.startedAt, + required this.target, + required this.loadedUntil, + required this.onCancel, + super.key, + }); + + double? get _progress { + final target = this.target; + final loadedUntil = this.loadedUntil; + if (target == null || loadedUntil == null) return null; + final total = startedAt.difference(target).inSeconds; + if (total <= 0) return null; + return (startedAt.difference(loadedUntil).inSeconds / total).clamp(0, 1); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final target = this.target; + final loadedUntil = this.loadedUntil; + return Material( + color: theme.colorScheme.surfaceContainer, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + const Icon(Icons.history, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + target != null + ? 'Springe zur Nachricht vom ${target.formatDate()}' + : 'Springe zur Nachricht', + style: theme.textTheme.bodyMedium, + ), + Text( + loadedUntil != null + ? 'Verlauf geladen bis ${loadedUntil.formatDate()}' + : 'Lade älteren Verlauf …', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + TextButton(onPressed: onCancel, child: const Text('Abbrechen')), + ], + ), + ), + LinearProgressIndicator(value: _progress), + ], + ), + ); + } +} diff --git a/test/view/talk/chat_search_controller_test.dart b/test/view/talk/chat_message_matcher_test.dart similarity index 56% rename from test/view/talk/chat_search_controller_test.dart rename to test/view/talk/chat_message_matcher_test.dart index 6d9fa37..a2b6112 100644 --- a/test/view/talk/chat_search_controller_test.dart +++ b/test/view/talk/chat_message_matcher_test.dart @@ -1,7 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/search/search_chat_messages_response.dart'; import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart'; -import 'package:marianum_mobile/view/pages/talk/data/chat_search_controller.dart'; +import 'package:marianum_mobile/view/pages/talk/data/chat_message_matcher.dart'; GetChatResponseObject _msg({ required int id, @@ -12,36 +13,35 @@ GetChatResponseObject _msg({ GetRoomResponseObjectMessageType type = GetRoomResponseObjectMessageType.comment, Map? params, -}) => - GetChatResponseObject( - id, - 'token', - GetRoomResponseObjectMessageActorType.user, - 'actor-id', - actorDisplayName, - timestamp, - systemMessage, - type, - true, - '', - message, - params, - null, - null, - null, - ); +}) => GetChatResponseObject( + id, + 'token', + GetRoomResponseObjectMessageActorType.user, + 'actor-id', + actorDisplayName, + timestamp, + systemMessage, + type, + true, + '', + message, + params, + null, + null, + null, +); GetChatResponse _response(List messages) => GetChatResponse(messages.toSet()); void main() { - group('ChatSearchController.findMatches', () { + group('ChatMessageMatcher.findMatches', () { test('empty query returns no matches', () { final response = _response([ _msg(id: 1, timestamp: 100, message: 'Hallo'), ]); - expect(ChatSearchController.findMatches(response, ''), isEmpty); - expect(ChatSearchController.findMatches(response, ' '), isEmpty); + expect(ChatMessageMatcher.findMatches(response, ''), isEmpty); + expect(ChatMessageMatcher.findMatches(response, ' '), isEmpty); }); test('matches message text case-insensitively', () { @@ -49,7 +49,7 @@ void main() { _msg(id: 1, timestamp: 100, message: 'Hallo Welt'), _msg(id: 2, timestamp: 200, message: 'nichts hier'), ]); - final matches = ChatSearchController.findMatches(response, 'WELT'); + final matches = ChatMessageMatcher.findMatches(response, 'WELT'); expect(matches.length, 1); expect(matches.first.messageId, 1); }); @@ -69,7 +69,7 @@ void main() { message: 'auch was', ), ]); - final matches = ChatSearchController.findMatches(response, 'lisa'); + final matches = ChatMessageMatcher.findMatches(response, 'lisa'); expect(matches.length, 1); expect(matches.first.messageId, 1); }); @@ -85,10 +85,7 @@ void main() { ), ]); // Match on text content - expect( - ChatSearchController.findMatches(response, 'beigetreten').length, - 1, - ); + expect(ChatMessageMatcher.findMatches(response, 'beigetreten').length, 1); // Actor name alone (not in text) should not match for system messages final actorOnlyResponse = _response([ _msg( @@ -100,7 +97,7 @@ void main() { ), ]); expect( - ChatSearchController.findMatches(actorOnlyResponse, 'lisa'), + ChatMessageMatcher.findMatches(actorOnlyResponse, 'lisa'), isEmpty, ); }); @@ -132,7 +129,7 @@ void main() { ), _msg(id: 3, timestamp: 300, message: 'Treffer'), ]); - final matches = ChatSearchController.findMatches(response, 'Treffer'); + final matches = ChatMessageMatcher.findMatches(response, 'Treffer'); expect(matches.length, 1); expect(matches.first.messageId, 3); }, @@ -155,7 +152,7 @@ void main() { }, ), ]); - final matches = ChatSearchController.findMatches(response, 'hausaufgab'); + final matches = ChatMessageMatcher.findMatches(response, 'hausaufgab'); expect(matches.length, 1); expect(matches.first.messageId, 1); }); @@ -166,8 +163,65 @@ void main() { _msg(id: 2, timestamp: 300, message: 'X'), _msg(id: 3, timestamp: 200, message: 'X'), ]); - final matches = ChatSearchController.findMatches(response, 'x'); + final matches = ChatMessageMatcher.findMatches(response, 'x'); expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]); }); }); + + group('ChatMessageMatcher.merge', () { + test('dedupes by id preferring local hits, newest id first', () { + const local = [ChatSearchHit(messageId: 5, text: 'lokal')]; + const server = [ + ChatSearchHit(messageId: 9, text: 'neu'), + ChatSearchHit(messageId: 5, text: 'server'), + ChatSearchHit(messageId: 2, text: 'alt'), + ]; + final merged = ChatMessageMatcher.merge(local, server); + expect(merged.map((h) => h.messageId).toList(), [9, 5, 2]); + expect(merged[1].text, 'lokal'); + }); + }); + + group('SearchChatMessagesEntry.fromJson', () { + test('reads id and timestamp from attributes', () { + final entry = SearchChatMessagesEntry.fromJson({ + 'title': 'Lisa Maier', + 'subline': 'Die Hausaufgabe ist bis Freitag', + 'attributes': { + 'messageId': '123', + 'actorId': 'lmaier', + 'timestamp': '1700000000', + }, + }, term: 'hausaufgabe')!; + expect(entry.messageId, 123); + expect(entry.actorId, 'lmaier'); + expect(entry.actorDisplayName, 'Lisa Maier'); + expect(entry.excerpt, 'Die Hausaufgabe ist bis Freitag'); + expect(entry.sentAt, DateTime.fromMillisecondsSinceEpoch(1700000000000)); + }); + + test('detects excerpt in title and falls back to resourceUrl id', () { + final entry = SearchChatMessagesEntry.fromJson({ + 'title': '…die Hausaufgabe', + 'subline': 'Lisa Maier', + 'resourceUrl': 'https://cloud/index.php/call/abc#message_77', + }, term: 'hausaufgabe')!; + expect(entry.messageId, 77); + expect(entry.excerpt, '…die Hausaufgabe'); + expect(entry.actorDisplayName, 'Lisa Maier'); + expect(entry.sentAt, isNull); + }); + + test('entries without message id are dropped', () { + final response = SearchChatMessagesResponse.fromJson({ + 'isPaginated': true, + 'cursor': 25, + 'entries': [ + {'title': 'x', 'subline': 'y'}, + ], + }, term: 'y'); + expect(response.entries, isEmpty); + expect(response.cursor, isNull); + }); + }); }