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
+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) =>
|
||||
|
||||
Reference in new issue
Block a user