Skip to content

Commit 7f1e560

Browse files
sm-sayediPIG208
andcommitted
topics: Keep topic-list page updated, by using data from Topics model
Fixes: #1499 Co-authored-by: Zixuan James Li <[email protected]>
1 parent d709da1 commit 7f1e560

File tree

3 files changed

+69
-39
lines changed

3 files changed

+69
-39
lines changed

lib/widgets/topic_list.dart

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../api/model/model.dart';
44
import '../api/route/channels.dart';
55
import '../generated/l10n/zulip_localizations.dart';
66
import '../model/narrow.dart';
7+
import '../model/topics.dart';
78
import '../model/unreads.dart';
89
import 'action_sheet.dart';
910
import 'app_bar.dart';
@@ -125,16 +126,16 @@ class _TopicList extends StatefulWidget {
125126

126127
class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin {
127128
Unreads? unreadsModel;
128-
// TODO(#1499): store the results on [ChannelStore], and keep them
129-
// up-to-date by handling events
130-
List<GetChannelTopicsEntry>? lastFetchedTopics;
129+
Topics? topicsModel;
131130

132131
@override
133132
void onNewStore() {
133+
final newStore = PerAccountStoreWidget.of(context);
134134
unreadsModel?.removeListener(_modelChanged);
135-
final store = PerAccountStoreWidget.of(context);
136-
unreadsModel = store.unreads..addListener(_modelChanged);
137-
_fetchTopics();
135+
unreadsModel = newStore.unreads..addListener(_modelChanged);
136+
topicsModel?.removeListener(_modelChanged);
137+
topicsModel = newStore.topics..addListener(_modelChanged);
138+
_fetchTopics(topicsModel!);
138139
}
139140

140141
@override
@@ -145,35 +146,33 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
145146

146147
void _modelChanged() {
147148
setState(() {
148-
// The actual state lives in `unreadsModel`.
149+
// The actual states lives in `unreadsModel` and `topicsModel`.
149150
});
150151
}
151152

152-
void _fetchTopics() async {
153+
void _fetchTopics(Topics topicsModel) async {
153154
// Do nothing when the fetch fails; the topic-list will stay on
154155
// the loading screen, until the user navigates away and back.
155156
// TODO(design) show a nice error message on screen when this fails
156-
final store = PerAccountStoreWidget.of(context);
157-
final result = await getChannelTopics(store.connection,
158-
channelId: widget.streamId,
159-
allowEmptyTopicName: true);
157+
await topicsModel.fetchChannelTopics(widget.streamId);
160158
if (!mounted) return;
161159
setState(() {
162-
lastFetchedTopics = result.topics;
160+
// The actual state lives in the `topicsModel`.
163161
});
164162
}
165163

166164
@override
167165
Widget build(BuildContext context) {
168-
if (lastFetchedTopics == null) {
166+
final channelTopics = topicsModel!.getChannelTopics(widget.streamId);
167+
if (channelTopics == null) {
169168
return const Center(child: CircularProgressIndicator());
170169
}
171170

172-
// TODO(design) handle the rare case when `lastFetchedTopics` is empty
171+
// TODO(#2003) handle the rare case when `channelTopics` is empty
173172

174173
// This is adapted from parts of the build method on [_InboxPageState].
175174
final topicItems = <_TopicItemData>[];
176-
for (final GetChannelTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
175+
for (final GetChannelTopicsEntry(:maxId, name: topic) in channelTopics) {
177176
final unreadMessageIds =
178177
unreadsModel!.streams[widget.streamId]?[topic] ?? <int>[];
179178
final countInTopic = unreadMessageIds.length;
@@ -183,17 +182,9 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
183182
topic: topic,
184183
unreadCount: countInTopic,
185184
hasMention: hasMention,
186-
// `lastFetchedTopics.maxId` can become outdated when a new message
187-
// arrives or when there are message moves, until we re-fetch.
188-
// TODO(#1499): track changes to this
189185
maxId: maxId,
190186
));
191187
}
192-
topicItems.sort((a, b) {
193-
final aMaxId = a.maxId;
194-
final bMaxId = b.maxId;
195-
return bMaxId.compareTo(aMaxId);
196-
});
197188

198189
return SafeArea(
199190
// Don't pad the bottom here; we want the list content to do that.

test/widgets/action_sheet_test.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,14 @@ void main() {
222222
channel ??= someChannel;
223223

224224
connection.prepare(json: eg.newestGetMessagesResult(
225-
foundOldest: true, messages: []).toJson());
226-
if (narrow case ChannelNarrow()) {
227-
// We auto-focus the topic input when there are no messages;
228-
// this is for topic autocomplete.
229-
connection.prepare(json: GetChannelTopicsResult(topics: []).toJson());
230-
}
225+
foundOldest: true,
226+
// Include one message so that we don't auto-focus the topic input,
227+
// which would trigger a topic-list fetch for topic autocomplete.
228+
// That's helpful for the test that opens the topic-list page.
229+
// With the topic-list fetch deferred until that page is opened,
230+
// the test can prepare the fetch response as it chooses.
231+
messages: [eg.streamMessage(stream: channel)],
232+
).toJson());
231233
await tester.pumpWidget(TestZulipApp(
232234
accountId: eg.selfAccount.id,
233235
child: MessageListPage(

test/widgets/topic_list_test.dart

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ void main() {
124124
check(find.byType(CircularProgressIndicator)).findsNothing();
125125
});
126126

127-
testWidgets('fetch again when navigating away and back', (tester) async {
127+
testWidgets("don't fetch again when navigating away and back", (tester) async {
128128
addTearDown(testBinding.reset);
129129
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
130130
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
@@ -145,20 +145,25 @@ void main() {
145145
await tester.tap(find.byIcon(ZulipIcons.topics));
146146
await tester.pump();
147147
await tester.pump(Duration.zero);
148+
check(connection.takeRequests())
149+
..length.equals(2) // one for the messages request, another for the topics
150+
..last.which((last) => last
151+
.isA<http.Request>()
152+
..method.equals('GET')
153+
..url.path.equals('/api/v1/users/me/${channel.streamId}/topics'));
148154
check(find.text('topic A')).findsOne();
149155

150156
// … go back to the message list page…
151157
await tester.pageBack();
152158
await tester.pump();
153159

154-
// … then back to the topic-list page, expecting to fetch again.
155-
connection.prepare(json: GetChannelTopicsResult(
156-
topics: [eg.getChannelTopicsEntry(name: 'topic B')]).toJson());
160+
// … then back to the topic-list page, expecting not to fetch again but
161+
// use existing data which is kept up-to-date anyway.
157162
await tester.tap(find.byIcon(ZulipIcons.topics));
158163
await tester.pump();
159164
await tester.pump(Duration.zero);
160-
check(find.text('topic A')).findsNothing();
161-
check(find.text('topic B')).findsOne();
165+
check(connection.takeRequests()).isEmpty();
166+
check(find.text('topic A')).findsOne();
162167
});
163168

164169
Finder topicItemFinder = find.descendant(
@@ -221,9 +226,7 @@ void main() {
221226
origMessages: [message],
222227
newTopicStr: 'bar'));
223228
await tester.pump();
224-
// There's still one topic item ("foo") even though the new message is in
225-
// topic "bar", but the topic list doesn't get updated.
226-
check(topicItemFinder).findsOne();
229+
check(topicItemFinder).findsExactly(2);
227230

228231
// After the move, the message with maxId moved away from "foo". The topic
229232
// actions that require `someMessageIdInTopic` are no longer available.
@@ -232,6 +235,40 @@ void main() {
232235
check(find.text('Mark as resolved')).findsNothing();
233236
await tester.tap(find.text('Cancel'));
234237
await tester.pump();
238+
239+
// Topic actions that require `someMessageIdInTopic` are available
240+
// for "bar", the new topic that the message moved to.
241+
await tester.longPress(find.text('bar'));
242+
await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation
243+
check(find.text('Mark as resolved')).findsOne();
244+
});
245+
246+
// event handling is more thoroughly tested in test/model/channel_test.dart
247+
testWidgets('smoke resolve topic from topic action sheet', (tester) async {
248+
final channel = eg.stream();
249+
final messages = List.generate(10, (i) =>
250+
eg.streamMessage(id: 100 + i, stream: channel, topic: 'foo'));
251+
252+
await prepare(tester, channel: channel,
253+
topics: [eg.getChannelTopicsEntry(maxId: 109, name: 'foo')],
254+
messages: messages);
255+
await tester.longPress(topicItemFinder);
256+
await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation
257+
check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check))).findsNothing();
258+
check(findInTopicItemAt(0, find.text('foo'))).findsOne();
259+
260+
connection.prepare(json: {});
261+
await tester.tap(find.text('Mark as resolved'));
262+
await tester.pump();
263+
await tester.pump(Duration.zero);
264+
265+
await store.handleEvent(eg.updateMessageEventMoveFrom(
266+
origMessages: messages,
267+
newTopic: eg.t('foo').resolve(),
268+
propagateMode: .changeAll));
269+
await tester.pump();
270+
check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check))).findsOne();
271+
check(findInTopicItemAt(0, find.text('foo'))).findsOne();
235272
});
236273

237274
testWidgets('resolved and unresolved topics', (tester) async {

0 commit comments

Comments
 (0)