Skip to content

Commit 2e0fcfd

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 b515da9 commit 2e0fcfd

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';
@@ -124,16 +125,13 @@ class _TopicList extends StatefulWidget {
124125

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

131129
@override
132130
void onNewStore() {
133131
unreadsModel?.removeListener(_modelChanged);
134132
final store = PerAccountStoreWidget.of(context);
135133
unreadsModel = store.unreads..addListener(_modelChanged);
136-
_fetchTopics();
134+
_fetchTopics(store);
137135
}
138136

139137
@override
@@ -148,31 +146,30 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
148146
});
149147
}
150148

151-
void _fetchTopics() async {
149+
void _fetchTopics(PerAccountStore store) async {
152150
// Do nothing when the fetch fails; the topic-list will stay on
153151
// the loading screen, until the user navigates away and back.
154152
// TODO(design) show a nice error message on screen when this fails
155-
final store = PerAccountStoreWidget.of(context);
156-
final result = await getStreamTopics(store.connection,
157-
streamId: widget.streamId,
158-
allowEmptyTopicName: true);
153+
await store.fetchTopics(widget.streamId);
159154
if (!mounted) return;
160155
setState(() {
161-
lastFetchedTopics = result.topics;
156+
// The actual state lives in the PerAccountStore.
162157
});
163158
}
164159

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

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

173170
// This is adapted from parts of the build method on [_InboxPageState].
174171
final topicItems = <_TopicItemData>[];
175-
for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
172+
for (final GetStreamTopicsEntry(:maxId, name: topic) in channelTopics) {
176173
final unreadMessageIds =
177174
unreadsModel!.streams[widget.streamId]?[topic] ?? <int>[];
178175
final countInTopic = unreadMessageIds.length;
@@ -182,17 +179,9 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
182179
topic: topic,
183180
unreadCount: countInTopic,
184181
hasMention: hasMention,
185-
// `lastFetchedTopics.maxId` can become outdated when a new message
186-
// arrives or when there are message moves, until we re-fetch.
187-
// TODO(#1499): track changes to this
188182
maxId: maxId,
189183
));
190184
}
191-
topicItems.sort((a, b) {
192-
final aMaxId = a.maxId;
193-
final bMaxId = b.maxId;
194-
return bMaxId.compareTo(aMaxId);
195-
});
196185

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

235224
final store = PerAccountStoreWidget.of(context);
236225
final designVariables = DesignVariables.of(context);
226+
// The message with `maxId` might not remain in `topic` since we last fetch
227+
// the list of topics. Make sure we check for that before passing `maxId`
228+
// to the topic action sheet.
229+
// See also: [ChannelStore.getChannelTopics]
230+
final message = store.messages[maxId];
231+
final isMaxIdInTopic = message is StreamMessage
232+
&& message.streamId == streamId
233+
&& message.topic.isSameAs(topic);
237234

238235
final visibilityPolicy = store.topicVisibilityPolicy(streamId, topic);
239236
final double opacity;
@@ -262,7 +259,7 @@ class _TopicItem extends StatelessWidget {
262259
onLongPress: () => showTopicActionSheet(context,
263260
channelId: streamId,
264261
topic: topic,
265-
someMessageIdInTopic: maxId),
262+
someMessageIdInTopic: isMaxIdInTopic ? maxId : null),
266263
splashFactory: NoSplash.splashFactory,
267264
child: Padding(padding: EdgeInsetsDirectional.fromSTEB(6, 8, 12, 8),
268265
child: Row(

test/widgets/action_sheet_test.dart

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,7 @@ void main() {
221221
channel ??= someChannel;
222222

223223
connection.prepare(json: eg.newestGetMessagesResult(
224-
foundOldest: true, messages: []).toJson());
225-
if (narrow case ChannelNarrow()) {
226-
// We auto-focus the topic input when there are no messages;
227-
// this is for topic autocomplete.
228-
connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
229-
}
224+
foundOldest: true, messages: [eg.streamMessage(stream: channel)]).toJson());
230225
await tester.pumpWidget(TestZulipApp(
231226
accountId: eg.selfAccount.id,
232227
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)