Skip to content

Commit 02ed253

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 f7deae4 commit 02ed253

File tree

3 files changed

+110
-40
lines changed

3 files changed

+110
-40
lines changed

lib/widgets/topic_list.dart

Lines changed: 18 additions & 21 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<GetStreamTopicsEntry>? 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 getStreamTopics(store.connection,
158-
streamId: widget.streamId,
159-
allowEmptyTopicName: true);
154+
await store.fetchTopics(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

172169
// TODO(design) handle the rare case when `lastFetchedTopics` is empty
173170

174171
// This is adapted from parts of the build method on [_InboxPageState].
175172
final topicItems = <_TopicItemData>[];
176-
for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
173+
for (final GetStreamTopicsEntry(: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+
// The message with `maxId` might not remain in `topic` since we last fetch
228+
// the list of topics. Make sure we check for that before passing `maxId`
229+
// to the topic action sheet.
230+
// See also: [ChannelStore.getChannelTopics]
231+
final message = store.messages[maxId];
232+
final isMaxIdInTopic = message is StreamMessage
233+
&& message.streamId == streamId
234+
&& message.topic.isSameAs(topic);
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: isMaxIdInTopic ? maxId : null),
267264
splashFactory: NoSplash.splashFactory,
268265
child: ConstrainedBox(
269266
constraints: BoxConstraints(minHeight: 40),

test/widgets/action_sheet_test.dart

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,7 @@ 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: GetStreamTopicsResult(topics: []).toJson());
230-
}
225+
foundOldest: true, messages: [eg.streamMessage(stream: channel)]).toJson());
231226
await tester.pumpWidget(TestZulipApp(
232227
accountId: eg.selfAccount.id,
233228
child: MessageListPage(

test/widgets/topic_list_test.dart

Lines changed: 91 additions & 13 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("dont'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,28 @@ 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.
162+
// … then back to the topic-list page, expecting not to fetch again but
163+
// use existing data which is kept up-to-date anyways.
155164
connection.prepare(json: GetStreamTopicsResult(
156165
topics: [eg.getStreamTopicsEntry(name: 'topic B')]).toJson());
157166
await tester.tap(find.byIcon(ZulipIcons.topics));
158167
await tester.pump();
159168
await tester.pump(Duration.zero);
160-
check(find.text('topic A')).findsNothing();
161-
check(find.text('topic B')).findsOne();
169+
check(connection.takeRequests()).isEmpty();
170+
check(find.text('topic B')).findsNothing();
171+
check(find.text('topic A')).findsOne();
162172
});
163173

164174
Finder topicItemFinder = find.descendant(
@@ -169,6 +179,18 @@ void main() {
169179
of: topicItemFinder.at(index),
170180
matching: finder);
171181

182+
testWidgets('sort topics by maxId', (tester) async {
183+
await prepare(tester, topics: [
184+
eg.getStreamTopicsEntry(name: 'A', maxId: 3),
185+
eg.getStreamTopicsEntry(name: 'B', maxId: 2),
186+
eg.getStreamTopicsEntry(name: 'C', maxId: 4),
187+
]);
188+
189+
check(findInTopicItemAt(0, find.text('C'))).findsOne();
190+
check(findInTopicItemAt(1, find.text('A'))).findsOne();
191+
check(findInTopicItemAt(2, find.text('B'))).findsOne();
192+
});
193+
172194
testWidgets('show topic action sheet', (tester) async {
173195
final channel = eg.stream();
174196
await prepare(tester, channel: channel,
@@ -190,16 +212,72 @@ void main() {
190212
});
191213
});
192214

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

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

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

0 commit comments

Comments
 (0)