Skip to content

Commit b008d16

Browse files
PIG208sm-sayedi
andcommitted
topics: Keep topic-list page updated, by using topic data from store
Fixes: #1499 Co-authored-by: Sayed Mahmood Sayedi <[email protected]>
1 parent 793f860 commit b008d16

File tree

3 files changed

+115
-43
lines changed

3 files changed

+115
-43
lines changed

lib/widgets/topic_list.dart

Lines changed: 19 additions & 22 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/store.dart';
78
import '../model/unreads.dart';
89
import 'action_sheet.dart';
910
import 'app_bar.dart';
@@ -125,16 +126,13 @@ 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;
131129

132130
@override
133131
void onNewStore() {
134132
unreadsModel?.removeListener(_modelChanged);
135133
final store = PerAccountStoreWidget.of(context);
136134
unreadsModel = store.unreads..addListener(_modelChanged);
137-
_fetchTopics();
135+
_fetchTopics(store);
138136
}
139137

140138
@override
@@ -149,31 +147,30 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
149147
});
150148
}
151149

152-
void _fetchTopics() async {
150+
void _fetchTopics(PerAccountStore store) async {
153151
// Do nothing when the fetch fails; the topic-list will stay on
154152
// the loading screen, until the user navigates away and back.
155153
// 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);
154+
await store.fetchChannelTopics(widget.streamId);
160155
if (!mounted) return;
161156
setState(() {
162-
lastFetchedTopics = result.topics;
157+
// The actual state lives in the PerAccountStore.
163158
});
164159
}
165160

166161
@override
167162
Widget build(BuildContext context) {
168-
if (lastFetchedTopics == null) {
163+
final store = PerAccountStoreWidget.of(context);
164+
final channelTopics = store.getChannelTopics(widget.streamId);
165+
if (channelTopics == null) {
169166
return const Center(child: CircularProgressIndicator());
170167
}
171168

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

174171
// This is adapted from parts of the build method on [_InboxPageState].
175172
final topicItems = <_TopicItemData>[];
176-
for (final GetChannelTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
173+
for (final GetChannelTopicsEntry(:maxId, name: topic) in channelTopics) {
177174
final unreadMessageIds =
178175
unreadsModel!.streams[widget.streamId]?[topic] ?? <int>[];
179176
final countInTopic = unreadMessageIds.length;
@@ -183,17 +180,9 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
183180
topic: topic,
184181
unreadCount: countInTopic,
185182
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
189183
maxId: maxId,
190184
));
191185
}
192-
topicItems.sort((a, b) {
193-
final aMaxId = a.maxId;
194-
final bMaxId = b.maxId;
195-
return bMaxId.compareTo(aMaxId);
196-
});
197186

198187
return SafeArea(
199188
// Don't pad the bottom here; we want the list content to do that.
@@ -235,6 +224,14 @@ class _TopicItem extends StatelessWidget {
235224

236225
final store = PerAccountStoreWidget.of(context);
237226
final designVariables = DesignVariables.of(context);
227+
// `maxId` might be incorrect (see [ChannelStore.getChannelTopics]).
228+
// Check if it refers to a message that's currently in the topic;
229+
// if not, we just won't have `someMessageIdInTopic` for the action sheet.
230+
final maxIdMessage = store.messages[maxId];
231+
final someMessageIdInTopic =
232+
(maxIdMessage != null && TopicNarrow(streamId, topic).containsMessage(maxIdMessage))
233+
? maxIdMessage.id
234+
: null;
238235

239236
final visibilityPolicy = store.topicVisibilityPolicy(streamId, topic);
240237
final double opacity;
@@ -263,7 +260,7 @@ class _TopicItem extends StatelessWidget {
263260
onLongPress: () => showTopicActionSheet(context,
264261
channelId: streamId,
265262
topic: topic,
266-
someMessageIdInTopic: maxId),
263+
someMessageIdInTopic: someMessageIdInTopic),
267264
splashFactory: NoSplash.splashFactory,
268265
child: ConstrainedBox(
269266
constraints: BoxConstraints(minHeight: 40),

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: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import 'package:zulip/widgets/message_list.dart';
1414
import 'package:zulip/widgets/topic_list.dart';
1515

1616
import '../api/fake_api.dart';
17+
import '../api/route/route_checks.dart';
1718
import '../example_data.dart' as eg;
1819
import '../model/binding.dart';
20+
import '../model/store_checks.dart';
1921
import '../model/test_store.dart';
2022
import '../stdlib_checks.dart';
2123
import 'test_app.dart';
@@ -124,7 +126,7 @@ void main() {
124126
check(find.byType(CircularProgressIndicator)).findsNothing();
125127
});
126128

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

150158
// … go back to the message list page…
151159
await tester.pageBack();
152160
await tester.pump();
153161

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());
162+
// … then back to the topic-list page, expecting not to fetch again but
163+
// use existing data which is kept up-to-date anyway.
157164
await tester.tap(find.byIcon(ZulipIcons.topics));
158165
await tester.pump();
159166
await tester.pump(Duration.zero);
160-
check(find.text('topic A')).findsNothing();
161-
check(find.text('topic B')).findsOne();
167+
check(connection.takeRequests()).isEmpty();
168+
check(find.text('topic A')).findsOne();
162169
});
163170

164171
Finder topicItemFinder = find.descendant(
@@ -169,6 +176,18 @@ void main() {
169176
of: topicItemFinder.at(index),
170177
matching: finder);
171178

179+
testWidgets('sort topics by maxId', (tester) async {
180+
await prepare(tester, topics: [
181+
eg.getChannelTopicsEntry(name: 'A', maxId: 3),
182+
eg.getChannelTopicsEntry(name: 'B', maxId: 2),
183+
eg.getChannelTopicsEntry(name: 'C', maxId: 4),
184+
]);
185+
186+
check(findInTopicItemAt(0, find.text('C'))).findsOne();
187+
check(findInTopicItemAt(1, find.text('A'))).findsOne();
188+
check(findInTopicItemAt(2, find.text('B'))).findsOne();
189+
});
190+
172191
testWidgets('show topic action sheet', (tester) async {
173192
final channel = eg.stream();
174193
await prepare(tester, channel: channel,
@@ -190,16 +209,70 @@ void main() {
190209
});
191210
});
192211

193-
testWidgets('sort topics by maxId', (tester) async {
194-
await prepare(tester, topics: [
195-
eg.getChannelTopicsEntry(name: 'A', maxId: 3),
196-
eg.getChannelTopicsEntry(name: 'B', maxId: 2),
197-
eg.getChannelTopicsEntry(name: 'C', maxId: 4),
198-
]);
212+
testWidgets('show topic action sheet before and after moves', (tester) async {
213+
final channel = eg.stream();
214+
final message = eg.streamMessage(id: 100, stream: channel, topic: 'foo');
215+
await prepare(tester, channel: channel,
216+
topics: [eg.getChannelTopicsEntry(name: 'foo', maxId: 100)],
217+
messages: [message]);
218+
check(store).getChannelTopics(channel.streamId).isNotNull().single
219+
.maxId.equals(100);
220+
check(topicItemFinder).findsOne();
221+
222+
// Before the move, "foo"'s maxId is known to be accurate. This makes
223+
// topic actions that require `someMessageIdInTopic` available.
224+
await tester.longPress(find.text('foo'));
225+
await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation
226+
check(find.text('Mark as resolved')).findsOne();
227+
await tester.tap(find.text('Cancel'));
199228

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

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

0 commit comments

Comments
 (0)