Skip to content

Commit 385ee8e

Browse files
committed
Merge remote-tracking branch 'pr/1566'
2 parents 0058cd7 + 8c1ba1f commit 385ee8e

File tree

7 files changed

+216
-51
lines changed

7 files changed

+216
-51
lines changed

lib/model/internal_link.dart

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,22 +109,43 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
109109
return result;
110110
}
111111

112-
/// A [Narrow] from a given URL, on `store`'s realm.
112+
/// The result of parsing some URL within a Zulip realm,
113+
/// when the URL corresponds to some page in this app.
114+
sealed class InternalLink {
115+
InternalLink({required this.realmUrl});
116+
117+
final Uri realmUrl;
118+
}
119+
120+
/// The result of parsing some URL that points to a narrow on a Zulip realm,
121+
/// when the narrow is of a type that this app understands.
122+
class NarrowLink extends InternalLink {
123+
NarrowLink(this.narrow, this.nearMessageId, {required super.realmUrl});
124+
125+
final Narrow narrow;
126+
final int? nearMessageId;
127+
}
128+
129+
/// Try to parse the given URL as a page in this app, on `store`'s realm.
113130
///
114131
/// `url` must already be a result from [PerAccountStore.tryResolveUrl]
115132
/// on `store`.
116133
///
117-
/// Returns `null` if any of the operator/operand pairs are invalid.
134+
/// Returns null if the URL isn't on this realm,
135+
/// or isn't a valid Zulip URL,
136+
/// or isn't currently supported as leading to a page in this app.
118137
///
138+
/// In particular this will return null if `url` is a `/#narrow/…` URL
139+
/// and any of the operator/operand pairs are invalid.
119140
/// Since narrow links can combine operators in ways our [Narrow] type can't
120141
/// represent, this can also return null for valid narrow links.
121142
///
122143
/// This can also return null for some valid narrow links that our Narrow
123144
/// type *could* accurately represent. We should try to understand these
124-
/// better, but some kinds will be rare, even unheard-of:
145+
/// better, but some kinds will be rare, even unheard-of. For example:
125146
/// #narrow/stream/1-announce/stream/1-announce (duplicated operator)
126147
// TODO(#252): handle all valid narrow links, returning a search narrow
127-
Narrow? parseInternalLink(Uri url, PerAccountStore store) {
148+
InternalLink? parseInternalLink(Uri url, PerAccountStore store) {
128149
if (!_isInternalLink(url, store.realmUrl)) return null;
129150

130151
final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
@@ -155,7 +176,7 @@ bool _isInternalLink(Uri url, Uri realmUrl) {
155176
return (category, segments);
156177
}
157178

158-
Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
179+
NarrowLink? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
159180
assert(segments.isNotEmpty);
160181
assert(segments.length.isEven);
161182

@@ -164,6 +185,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
164185
ApiNarrowDm? dmElement;
165186
ApiNarrowWith? withElement;
166187
Set<IsOperand> isElementOperands = {};
188+
int? nearMessageId;
167189

168190
for (var i = 0; i < segments.length; i += 2) {
169191
final (operator, negated) = _parseOperator(segments[i]);
@@ -201,24 +223,26 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
201223
// It is fine to have duplicates of the same [IsOperand].
202224
isElementOperands.add(IsOperand.fromRawString(operand));
203225

204-
case _NarrowOperator.near: // TODO(#82): support for near
205-
continue;
226+
case _NarrowOperator.near:
227+
if (nearMessageId != null) return null;
228+
nearMessageId = int.tryParse(operand, radix: 10);
206229

207230
case _NarrowOperator.unknown:
208231
return null;
209232
}
210233
}
211234

235+
final Narrow? narrow;
212236
if (isElementOperands.isNotEmpty) {
213237
if (streamElement != null || topicElement != null || dmElement != null || withElement != null) {
214238
return null;
215239
}
216240
if (isElementOperands.length > 1) return null;
217241
switch (isElementOperands.single) {
218242
case IsOperand.mentioned:
219-
return const MentionsNarrow();
243+
narrow = const MentionsNarrow();
220244
case IsOperand.starred:
221-
return const StarredMessagesNarrow();
245+
narrow = const StarredMessagesNarrow();
222246
case IsOperand.dm:
223247
case IsOperand.private:
224248
case IsOperand.alerted:
@@ -230,17 +254,20 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
230254
}
231255
} else if (dmElement != null) {
232256
if (streamElement != null || topicElement != null || withElement != null) return null;
233-
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
257+
narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
234258
} else if (streamElement != null) {
235259
final streamId = streamElement.operand;
236260
if (topicElement != null) {
237-
return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
261+
narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
238262
} else {
239263
if (withElement != null) return null;
240-
return ChannelNarrow(streamId);
264+
narrow = ChannelNarrow(streamId);
241265
}
266+
} else {
267+
return null;
242268
}
243-
return null;
269+
270+
return NarrowLink(narrow, nearMessageId, realmUrl: store.realmUrl);
244271
}
245272

246273
@JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)

lib/model/message_list.dart

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
479479
/// which might be made internally by this class in order to
480480
/// fetch the messages from scratch, e.g. after certain events.
481481
Anchor get anchor => _anchor;
482-
final Anchor _anchor;
482+
Anchor _anchor;
483483

484484
void _register() {
485485
store.registerMessageList(this);
@@ -756,6 +756,20 @@ class MessageListView with ChangeNotifier, _MessageSequence {
756756
}
757757
}
758758

759+
/// Reset this view to start from the newest messages.
760+
///
761+
/// This will set [anchor] to [AnchorCode.newest],
762+
/// and cause messages to be re-fetched from scratch.
763+
void jumpToEnd() {
764+
assert(fetched);
765+
assert(!haveNewest);
766+
assert(anchor != AnchorCode.newest);
767+
_anchor = AnchorCode.newest;
768+
_reset();
769+
notifyListeners();
770+
fetchInitial();
771+
}
772+
759773
/// Add [outboxMessage] if it belongs to the view.
760774
void addOutboxMessage(OutboxMessage outboxMessage) {
761775
// TODO(#1441) implement this

lib/notifications/display.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ class NotificationDisplayManager {
513513

514514
return MessageListPage.buildRoute(
515515
accountId: account.id,
516-
// TODO(#82): Open at specific message, not just conversation
516+
// TODO(#1565): Open at specific message, not just conversation
517517
narrow: payload.narrow);
518518
}
519519

lib/widgets/content.dart

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,15 +1536,18 @@ void _launchUrl(BuildContext context, String urlString) async {
15361536
return;
15371537
}
15381538

1539-
final internalNarrow = parseInternalLink(url, store);
1540-
if (internalNarrow != null) {
1541-
unawaited(Navigator.push(context,
1542-
MessageListPage.buildRoute(context: context,
1543-
narrow: internalNarrow)));
1544-
return;
1539+
final internalLink = parseInternalLink(url, store);
1540+
assert(internalLink == null || internalLink.realmUrl == store.realmUrl);
1541+
switch (internalLink) {
1542+
case NarrowLink():
1543+
unawaited(Navigator.push(context,
1544+
MessageListPage.buildRoute(context: context,
1545+
narrow: internalLink.narrow,
1546+
initAnchorMessageId: internalLink.nearMessageId)));
1547+
1548+
case null:
1549+
await PlatformActions.launchUrl(context, url);
15451550
}
1546-
1547-
await PlatformActions.launchUrl(context, url);
15481551
}
15491552

15501553
/// Like [Image.network], but includes [authHeader] if [src] is on-realm.

lib/widgets/message_list.dart

Lines changed: 78 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,17 @@ abstract class MessageListPageState {
141141
}
142142

143143
class MessageListPage extends StatefulWidget {
144-
const MessageListPage({super.key, required this.initNarrow});
144+
const MessageListPage({
145+
super.key,
146+
required this.initNarrow,
147+
this.initAnchorMessageId,
148+
});
145149

146150
static AccountRoute<void> buildRoute({int? accountId, BuildContext? context,
147-
required Narrow narrow}) {
151+
required Narrow narrow, int? initAnchorMessageId}) {
148152
return MaterialAccountWidgetRoute(accountId: accountId, context: context,
149-
page: MessageListPage(initNarrow: narrow));
153+
page: MessageListPage(
154+
initNarrow: narrow, initAnchorMessageId: initAnchorMessageId));
150155
}
151156

152157
/// The [MessageListPageState] above this context in the tree.
@@ -162,6 +167,7 @@ class MessageListPage extends StatefulWidget {
162167
}
163168

164169
final Narrow initNarrow;
170+
final int? initAnchorMessageId; // TODO(#1564) highlight target upon load
165171

166172
@override
167173
State<MessageListPage> createState() => _MessageListPageState();
@@ -240,6 +246,10 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
240246
actions.add(_TopicListButton(streamId: streamId));
241247
}
242248

249+
// TODO(#80): default to anchor firstUnread, instead of newest
250+
final initAnchor = widget.initAnchorMessageId == null
251+
? AnchorCode.newest : NumericAnchor(widget.initAnchorMessageId!);
252+
243253
// Insert a PageRoot here, to provide a context that can be used for
244254
// MessageListPage.ancestorOf.
245255
return PageRoot(child: Scaffold(
@@ -259,7 +269,8 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
259269
// we matched to the Figma in 21dbae120. See another frame, which uses that:
260270
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev
261271
body: Builder(
262-
builder: (BuildContext context) => Column(
272+
builder: (BuildContext context) {
273+
return Column(
263274
// Children are expected to take the full horizontal space
264275
// and handle the horizontal device insets.
265276
// The bottom inset should be handled by the last child only.
@@ -279,11 +290,13 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
279290
child: MessageList(
280291
key: _messageListKey,
281292
narrow: narrow,
293+
initAnchor: initAnchor,
282294
onNarrowChanged: _narrowChanged,
283295
))),
284296
if (ComposeBox.hasComposeBox(narrow))
285297
ComposeBox(key: _composeBoxKey, narrow: narrow)
286-
]))));
298+
]);
299+
})));
287300
}
288301
}
289302

@@ -479,9 +492,15 @@ const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMes
479492
/// When there is no [ComposeBox], also takes responsibility
480493
/// for dealing with the bottom inset.
481494
class MessageList extends StatefulWidget {
482-
const MessageList({super.key, required this.narrow, required this.onNarrowChanged});
495+
const MessageList({
496+
super.key,
497+
required this.narrow,
498+
required this.initAnchor,
499+
required this.onNarrowChanged,
500+
});
483501

484502
final Narrow narrow;
503+
final Anchor initAnchor;
485504
final void Function(Narrow newNarrow) onNarrowChanged;
486505

487506
@override
@@ -504,8 +523,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
504523

505524
@override
506525
void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages
526+
final anchor = _model == null ? widget.initAnchor : _model!.anchor;
507527
_model?.dispose();
508-
_initModel(PerAccountStoreWidget.of(context));
528+
_initModel(PerAccountStoreWidget.of(context), anchor);
509529
}
510530

511531
@override
@@ -516,10 +536,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
516536
super.dispose();
517537
}
518538

519-
void _initModel(PerAccountStore store) {
520-
// TODO(#82): get anchor as page/route argument, instead of using newest
521-
// TODO(#80): default to anchor firstUnread, instead of newest
522-
final anchor = AnchorCode.newest;
539+
void _initModel(PerAccountStore store, Anchor anchor) {
523540
_model = MessageListView.init(store: store,
524541
narrow: widget.narrow, anchor: anchor);
525542
model.addListener(_modelChanged);
@@ -535,6 +552,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
535552
// redirected us to the new location of the operand message ID.
536553
widget.onNarrowChanged(model.narrow);
537554
}
555+
// TODO when model reset, reset scroll
538556
setState(() {
539557
// The actual state lives in the [MessageListView] model.
540558
// This method was called because that just changed.
@@ -557,6 +575,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
557575
// still not yet updated to account for the newly-added messages.
558576
model.fetchOlder();
559577
}
578+
if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {
579+
model.fetchNewer();
580+
}
560581
}
561582

562583
void _scrollChanged() {
@@ -607,6 +628,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
607628
// MessageList's dartdoc.
608629
child: SafeArea(
609630
child: ScrollToBottomButton(
631+
model: model,
610632
scrollController: scrollController,
611633
visible: _scrollToBottomVisible))),
612634
])))));
@@ -742,13 +764,21 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
742764
}
743765

744766
Widget _buildEndCap() {
745-
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
746-
TypingStatusWidget(narrow: widget.narrow),
747-
MarkAsReadWidget(narrow: widget.narrow),
748-
// To reinforce that the end of the feed has been reached:
749-
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
750-
const SizedBox(height: 36),
751-
]);
767+
if (model.haveNewest) {
768+
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
769+
TypingStatusWidget(narrow: widget.narrow),
770+
// TODO perhaps offer mark-as-read even when not done fetching?
771+
MarkAsReadWidget(narrow: widget.narrow),
772+
// To reinforce that the end of the feed has been reached:
773+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
774+
const SizedBox(height: 36),
775+
]);
776+
} else if (model.busyFetchingMore) {
777+
// See [_buildStartCap] for why this condition shows a loading indicator.
778+
return const _MessageListLoadingMore();
779+
} else {
780+
return SizedBox.shrink();
781+
}
752782
}
753783

754784
Widget _buildItem(MessageListItem data) {
@@ -798,13 +828,40 @@ class _MessageListLoadingMore extends StatelessWidget {
798828
}
799829

800830
class ScrollToBottomButton extends StatelessWidget {
801-
const ScrollToBottomButton({super.key, required this.scrollController, required this.visible});
831+
const ScrollToBottomButton({
832+
super.key,
833+
required this.model,
834+
required this.scrollController,
835+
required this.visible,
836+
});
802837

803-
final ValueNotifier<bool> visible;
838+
final MessageListView model;
804839
final MessageListScrollController scrollController;
840+
final ValueNotifier<bool> visible;
805841

806842
void _scrollToBottom() {
807-
scrollController.position.scrollToEnd();
843+
if (model.haveNewest) {
844+
// Scrolling smoothly from here to the bottom won't require any requests
845+
// to the server.
846+
// It also probably isn't *that* far away: the user must have scrolled
847+
// here from there (or from near enough that a fetch reached there),
848+
// so scrolling back there -- at top speed -- shouldn't take too long.
849+
// Go for it.
850+
scrollController.position.scrollToEnd();
851+
} else {
852+
// This message list doesn't have the messages for the bottom of history.
853+
// There could be quite a lot of history between here and there --
854+
// for example, at first unread in the combined feed or a busy channel,
855+
// for a user who has some old unreads going back months and years.
856+
// In that case trying to scroll smoothly to the bottom is hopeless.
857+
//
858+
// Given that there were at least 100 messages between this message list's
859+
// initial anchor and the end of history (or else `fetchInitial` would
860+
// have reached the end at the outset), that situation is very likely.
861+
// Even if the end is close by, it's at least one fetch away.
862+
// Instead of scrolling, jump to the end, which is always just one fetch.
863+
model.jumpToEnd();
864+
}
808865
}
809866

810867
@override

0 commit comments

Comments
 (0)