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
This commit is contained in:
1 parent
fcfd21bb4d
commit
b9cb6a8af1
13 files changed
+926
-322
No files matched your search
@@ -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<SearchChatMessagesResponse> 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<String, dynamic>;
|
||||
return SearchChatMessagesResponse.fromJson(data, term: term);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/// Parsed page of Talk's `talk-message-current` search provider.
|
||||
class SearchChatMessagesResponse {
|
||||
final List<SearchChatMessagesEntry> 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<String, dynamic> json, {
|
||||
required String term,
|
||||
}) {
|
||||
final rawEntries = json['entries'];
|
||||
final entries = rawEntries is List
|
||||
? rawEntries
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => SearchChatMessagesEntry.fromJson(e, term: term))
|
||||
.whereType<SearchChatMessagesEntry>()
|
||||
.toList()
|
||||
: const <SearchChatMessagesEntry>[];
|
||||
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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<bool> loadOlderUntil(
|
||||
int messageId, {
|
||||
required bool Function() isCancelled,
|
||||
ValueChanged<DateTime>? 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<void> sendServerReadMarker(String token, int messageId) async {
|
||||
try {
|
||||
await SetReadMarker(
|
||||
|
||||
+119
-156
@@ -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<ChatView> with RouteAware {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _positionsListener =
|
||||
ItemPositionsListener.create();
|
||||
final TextEditingController _searchTextController = TextEditingController();
|
||||
final Map<int, int> _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<ChatSearchMatch> _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<ChatView> 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<ChatView> 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<ChatView> with RouteAware {
|
||||
List<Widget> _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<ChatView> with RouteAware {
|
||||
return _items = items;
|
||||
}
|
||||
|
||||
void _enterSearchMode() => _resetSearch(active: true);
|
||||
|
||||
void _exitSearchMode() {
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
_matchIndices.clear();
|
||||
_resetSearch(active: false);
|
||||
Future<void> _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<ChatSearchHit?>(
|
||||
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<void> _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<Widget> _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 = <Widget>[];
|
||||
final chronologicalMatchIndex = <int, int>{};
|
||||
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<ChatView> 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<ChatView> 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<ChatView> 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<ChatBloc, ChatState>(
|
||||
isReady: (state) =>
|
||||
|
||||
@@ -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<ChatSearchHit> findMatches(
|
||||
GetChatResponse response,
|
||||
String query,
|
||||
) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return const [];
|
||||
|
||||
final matches = <ChatSearchHit>[];
|
||||
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<ChatSearchHit> merge(
|
||||
List<ChatSearchHit> local,
|
||||
List<ChatSearchHit> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<ChatSearchMatch> findMatches(
|
||||
GetChatResponse response,
|
||||
String query,
|
||||
) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return const [];
|
||||
|
||||
final matches = <ChatSearchMatch>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ChatSearchHit> _localResults = const [];
|
||||
List<ChatSearchHit> _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<ChatSearchHit> 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<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<ChatSearchHit?> {
|
||||
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<Widget>? 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatSearchHit> onResultTap;
|
||||
|
||||
const ChatSearchResults({
|
||||
required this.controller,
|
||||
required this.onResultTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocModule<LoadableStateBloc, LoadableStateState>(
|
||||
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<ChatSearchHit> hits) {
|
||||
final showFooter = controller.canLoadMore || controller.loadingMore;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatBubble>
|
||||
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,
|
||||
|
||||
@@ -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<String> 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+86
-32
@@ -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<String, RichObjectString>? 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<GetChatResponseObject> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user