Implement mark-as-read-on-scroll, v1

(edited)

Fixes #81. 🎉

Still to do, but can be soon-after-launch:

- #1583
- #685

Mark messages as read on scroll, configurably
This Zulip feature: https://zulip.com/help/marking-messages-as-read#configure-whether-messages-are-automatically-marked-as-read

That means:
* When scrolling through messages that are unread, by default mark them as read with a request to the server.
* Control that behavior with a local setting "Mark messages as read on scroll", with three values: https://zulip.com/help/marking-messages-as-read#configure-whether-messages-are-automatically-marked-as-read
* Unlike most everything else in the Zulip data model, apply "latency compensation" when marking messages as read on scroll, meaning eagerly apply the change to the local UI rather than wait for the server to acknowledge it with an event.
  * Specifically, eagerly fade out the unread-message markers in the message list.

That local setting should go in the app's database on the device, from #13.

This should come after:
* #79
* #80

> Specifically, eagerly fade out the unread-message markers in the message list.

We might go further and eagerly update the model itself.
* The zulip-mobile RN app doesn't do this; the consequence is that it looks kind of glitchy as the "N unreads" banner at the top of the message list doesn't update or disappear in sync with the unread markers.
* I'm not actually sure what the web app does.
* The downside is that if we eagerly update the model, then it's no longer part of a coherent snapshot together with the rest of our model — we'd be giving up some of the consistency provided by the Zulip event system.

There are a couple of approaches we could take to that consistency question, but we should think through the consequences. So, leaving that out of scope for this issue.

We've had a request for this as beta feedback: https://chat.zulip.org/#narrow/channel/48-mobile/topic/mobile.20beta.20feedback/near/2000430

> Is there a way to turn on `Mark messages as read on scroll` in this beta? I'd gladly use beta app more often, but it bugs me out to tap Mark messages as read every time.
And [some more requests](https://chat.zulip.org/#narrow/channel/48-mobile/topic/marking.20read/near/2018451):

> here's another vote for prioritizing "Mark messages as read on scroll" support in the Flutter app.

> […] FWIW this is the main reason why I have not switched to the beta app. I'm subscribed to all channels here, and the fact that I have to continually mark messages as read manually makes the beta app very tedious for me to use -- I am manually marking hundreds if not thousands of messages as read every day.

flutter test test/model/settings_test.dart test/widgets/action_sheet_test.dart test/widgets/autocomplete_test.dart test/widgets/compose_box_test.dart test/widgets/emoji_reaction_test.dart test/widgets/lightbox_test.dart test/widgets/message_list_test.dart --reporter expanded 2>&1

diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index e20d220aba..e03f421761 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -971,6 +971,30 @@
   "@initialAnchorSettingNewestAlways":  {
     "description": "Label for a value of setting controlling initial anchor of message list."
   },
+  "markReadOnScrollSettingTitle": "Mark messages as read on scroll",
+  "@markReadOnScrollSettingTitle": {
+    "description": "Title of setting controlling which message-list views should mark read on scroll."
+  },
+  "markReadOnScrollSettingDescription": "When scrolling through messages, should they automatically be marked as read?",
+  "@markReadOnScrollSettingDescription": {
+    "description": "Description of setting controlling which message-list views should mark read on scroll."
+  },
+  "markReadOnScrollSettingAlways": "Always",
+  "@markReadOnScrollSettingAlways":  {
+    "description": "Label for a value of setting controlling which message-list views should mark read on scroll."
+  },
+  "markReadOnScrollSettingNever": "Never",
+  "@markReadOnScrollSettingNever":  {
+    "description": "Label for a value of setting controlling which message-list views should mark read on scroll."
+  },
+  "markReadOnScrollSettingConversations": "Only in conversation views",
+  "@markReadOnScrollSettingConversations":  {
+    "description": "Label for a value of setting controlling which message-list views should mark read on scroll."
+  },
+  "markReadOnScrollSettingConversationsDescription": "Messages will be automatically marked as read only when viewing a single topic or direct message conversation.",
+  "@markReadOnScrollSettingConversationsDescription":  {
+    "description": "Description for a value of setting controlling which message-list views should mark read on scroll."
+  },
   "experimentalFeatureSettingsPageTitle": "Experimental features",
   "@experimentalFeatureSettingsPageTitle": {
     "description": "Title of settings page for experimental, in-development features"
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 7f6ed24d64..c13cdd3a9f 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -1449,6 +1449,42 @@ abstract class ZulipLocalizations {
   /// **'Newest message'**
   String get initialAnchorSettingNewestAlways;

+  /// Title of setting controlling which message-list views should mark read on scroll.
+  ///
+  /// In en, this message translates to:
+  /// **'Mark messages as read on scroll'**
+  String get markReadOnScrollSettingTitle;
+
+  /// Description of setting controlling which message-list views should mark read on scroll.
+  ///
+  /// In en, this message translates to:
+  /// **'When scrolling through messages, should they automatically be marked as read?'**
+  String get markReadOnScrollSettingDescription;
+
+  /// Label for a value of setting controlling which message-list views should mark read on scroll.
+  ///
+  /// In en, this message translates to:
+  /// **'Always'**
+  String get markReadOnScrollSettingAlways;
+
+  /// Label for a value of setting controlling which message-list views should mark read on scroll.
+  ///
+  /// In en, this message translates to:
+  /// **'Never'**
+  String get markReadOnScrollSettingNever;
+
+  /// Label for a value of setting controlling which message-list views should mark read on scroll.
+  ///
+  /// In en, this message translates to:
+  /// **'Only in conversation views'**
+  String get markReadOnScrollSettingConversations;
+
+  /// Description for a value of setting controlling which message-list views should mark read on scroll.
+  ///
+  /// In en, this message translates to:
+  /// **'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.'**
+  String get markReadOnScrollSettingConversationsDescription;
+
   /// Title of settings page for experimental, in-development features
   ///
   /// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index 5965ddf7a9..c47095ee06 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index d7120eb264..301f70d34d 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 6830703b27..52e4393767 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 84f094451e..084eedfbbc 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 5526921c24..1fdc2f585d 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 5751493e04..4bdd16533d 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 89d9be82b0..3943e5c01d 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -801,6 +801,27 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Funkcje eksperymentalne';

diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index a7f088b027..13a6729b9b 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -804,6 +804,27 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle =>
       'Экспериментальные функции';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 6e8365012e..29e203cccb 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -792,6 +792,27 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 89dd4d28ca..c69ff12045 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -812,6 +812,27 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Eksperimentalne funkcije';

diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 5f8f37f617..6c5e7264d1 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -805,6 +805,27 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції';

diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index bbe12e4038..d61e7cd6e3 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -790,6 +790,27 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   @override
   String get initialAnchorSettingNewestAlways => 'Newest message';

+  @override
+  String get markReadOnScrollSettingTitle => 'Mark messages as read on scroll';
+
+  @override
+  String get markReadOnScrollSettingDescription =>
+      'When scrolling through messages, should they automatically be marked as read?';
+
+  @override
+  String get markReadOnScrollSettingAlways => 'Always';
+
+  @override
+  String get markReadOnScrollSettingNever => 'Never';
+
+  @override
+  String get markReadOnScrollSettingConversations =>
+      'Only in conversation views';
+
+  @override
+  String get markReadOnScrollSettingConversationsDescription =>
+      'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';

diff --git a/lib/model/database.dart b/lib/model/database.dart
index f7d85b4b95..e20380b9e7 100644
--- a/lib/model/database.dart
+++ b/lib/model/database.dart
@@ -27,6 +27,9 @@ class GlobalSettings extends Table {
   Column<String> get visitFirstUnread => textEnum<VisitFirstUnreadSetting>()
     .nullable()();

+  Column<String> get markReadOnScroll => textEnum<MarkReadOnScrollSetting>()
+    .nullable()();
+
   // If adding a new column to this table, consider whether [BoolGlobalSettings]
   // can do the job instead (by adding a value to the [BoolGlobalSetting] enum).
   // That way is more convenient, when it works, because
@@ -122,7 +125,7 @@ class AppDatabase extends _$AppDatabase {
   //    information on using the build_runner.
   //  * Write a migration in `_migrationSteps` below.
   //  * Write tests.
-  static const int latestSchemaVersion = 7; // See note.
+  static const int latestSchemaVersion = 8; // See note.

   @override
   int get schemaVersion => latestSchemaVersion;
@@ -181,6 +184,10 @@ class AppDatabase extends _$AppDatabase {
       await m.addColumn(schema.globalSettings,
         schema.globalSettings.visitFirstUnread);
     },
+    from7To8: (m, schema) async {
+      await m.addColumn(schema.globalSettings,
+        schema.globalSettings.markReadOnScroll);
+    },
   );

   Future<void> _createLatestSchema(Migrator m) async {
diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart
index 9ff8b71b65..d78f7ede84 100644
--- a/lib/model/database.g.dart
+++ b/lib/model/database.g.dart
@@ -45,10 +45,23 @@ class $GlobalSettingsTable extends GlobalSettings
         $GlobalSettingsTable.$convertervisitFirstUnreadn,
       );
   @override
+  late final GeneratedColumnWithTypeConverter<MarkReadOnScrollSetting?, String>
+  markReadOnScroll =
+      GeneratedColumn<String>(
+        'mark_read_on_scroll',
+        aliasedName,
+        true,
+        type: DriftSqlType.string,
+        requiredDuringInsert: false,
+      ).withConverter<MarkReadOnScrollSetting?>(
+        $GlobalSettingsTable.$convertermarkReadOnScrolln,
+      );
+  @override
   List<GeneratedColumn> get $columns => [
     themeSetting,
     browserPreference,
     visitFirstUnread,
+    markReadOnScroll,
   ];
   @override
   String get aliasedName => _alias ?? actualTableName;
@@ -81,6 +94,13 @@ class $GlobalSettingsTable extends GlobalSettings
               data['${effectivePrefix}visit_first_unread'],
             ),
           ),
+      markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln
+          .fromSql(
+            attachedDatabase.typeMapping.read(
+              DriftSqlType.string,
+              data['${effectivePrefix}mark_read_on_scroll'],
+            ),
+          ),
     );
   }

@@ -113,6 +133,14 @@ class $GlobalSettingsTable extends GlobalSettings
   $convertervisitFirstUnreadn = JsonTypeConverter2.asNullable(
     $convertervisitFirstUnread,
   );
+  static JsonTypeConverter2<MarkReadOnScrollSetting, String, String>
+  $convertermarkReadOnScroll = const EnumNameConverter<MarkReadOnScrollSetting>(
+    MarkReadOnScrollSetting.values,
+  );
+  static JsonTypeConverter2<MarkReadOnScrollSetting?, String?, String?>
+  $convertermarkReadOnScrolln = JsonTypeConverter2.asNullable(
+    $convertermarkReadOnScroll,
+  );
 }

 class GlobalSettingsData extends DataClass
@@ -120,10 +148,12 @@ class GlobalSettingsData extends DataClass
   final ThemeSetting? themeSetting;
   final BrowserPreference? browserPreference;
   final VisitFirstUnreadSetting? visitFirstUnread;
+  final MarkReadOnScrollSetting? markReadOnScroll;
   const GlobalSettingsData({
     this.themeSetting,
     this.browserPreference,
     this.visitFirstUnread,
+    this.markReadOnScroll,
   });
   @override
   Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -147,6 +177,13 @@ class GlobalSettingsData extends DataClass
         ),
       );
     }
+    if (!nullToAbsent || markReadOnScroll != null) {
+      map['mark_read_on_scroll'] = Variable<String>(
+        $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql(
+          markReadOnScroll,
+        ),
+      );
+    }
     return map;
   }

@@ -161,6 +198,9 @@ class GlobalSettingsData extends DataClass
       visitFirstUnread: visitFirstUnread == null && nullToAbsent
           ? const Value.absent()
           : Value(visitFirstUnread),
+      markReadOnScroll: markReadOnScroll == null && nullToAbsent
+          ? const Value.absent()
+          : Value(markReadOnScroll),
     );
   }

@@ -177,6 +217,8 @@ class GlobalSettingsData extends DataClass
           .fromJson(serializer.fromJson<String?>(json['browserPreference'])),
       visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn
           .fromJson(serializer.fromJson<String?>(json['visitFirstUnread'])),
+      markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln
+          .fromJson(serializer.fromJson<String?>(json['markReadOnScroll'])),
     );
   }
   @override
@@ -196,6 +238,11 @@ class GlobalSettingsData extends DataClass
           visitFirstUnread,
         ),
       ),
+      'markReadOnScroll': serializer.toJson<String?>(
+        $GlobalSettingsTable.$convertermarkReadOnScrolln.toJson(
+          markReadOnScroll,
+        ),
+      ),
     };
   }

@@ -203,6 +250,7 @@ class GlobalSettingsData extends DataClass
     Value<ThemeSetting?> themeSetting = const Value.absent(),
     Value<BrowserPreference?> browserPreference = const Value.absent(),
     Value<VisitFirstUnreadSetting?> visitFirstUnread = const Value.absent(),
+    Value<MarkReadOnScrollSetting?> markReadOnScroll = const Value.absent(),
   }) => GlobalSettingsData(
     themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting,
     browserPreference: browserPreference.present
@@ -211,6 +259,9 @@ class GlobalSettingsData extends DataClass
     visitFirstUnread: visitFirstUnread.present
         ? visitFirstUnread.value
         : this.visitFirstUnread,
+    markReadOnScroll: markReadOnScroll.present
+        ? markReadOnScroll.value
+        : this.markReadOnScroll,
   );
   GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
     return GlobalSettingsData(
@@ -223,6 +274,9 @@ class GlobalSettingsData extends DataClass
       visitFirstUnread: data.visitFirstUnread.present
           ? data.visitFirstUnread.value
           : this.visitFirstUnread,
+      markReadOnScroll: data.markReadOnScroll.present
+          ? data.markReadOnScroll.value
+          : this.markReadOnScroll,
     );
   }

@@ -231,50 +285,61 @@ class GlobalSettingsData extends DataClass
     return (StringBuffer('GlobalSettingsData(')
           ..write('themeSetting: $themeSetting, ')
           ..write('browserPreference: $browserPreference, ')
-          ..write('visitFirstUnread: $visitFirstUnread')
+          ..write('visitFirstUnread: $visitFirstUnread, ')
+          ..write('markReadOnScroll: $markReadOnScroll')
           ..write(')'))
         .toString();
   }

   @override
-  int get hashCode =>
-      Object.hash(themeSetting, browserPreference, visitFirstUnread);
+  int get hashCode => Object.hash(
+    themeSetting,
+    browserPreference,
+    visitFirstUnread,
+    markReadOnScroll,
+  );
   @override
   bool operator ==(Object other) =>
       identical(this, other) ||
       (other is GlobalSettingsData &&
           other.themeSetting == this.themeSetting &&
           other.browserPreference == this.browserPreference &&
-          other.visitFirstUnread == this.visitFirstUnread);
+          other.visitFirstUnread == this.visitFirstUnread &&
+          other.markReadOnScroll == this.markReadOnScroll);
 }

 class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
   final Value<ThemeSetting?> themeSetting;
   final Value<BrowserPreference?> browserPreference;
   final Value<VisitFirstUnreadSetting?> visitFirstUnread;
+  final Value<MarkReadOnScrollSetting?> markReadOnScroll;
   final Value<int> rowid;
   const GlobalSettingsCompanion({
     this.themeSetting = const Value.absent(),
     this.browserPreference = const Value.absent(),
     this.visitFirstUnread = const Value.absent(),
+    this.markReadOnScroll = const Value.absent(),
     this.rowid = const Value.absent(),
   });
   GlobalSettingsCompanion.insert({
     this.themeSetting = const Value.absent(),
     this.browserPreference = const Value.absent(),
     this.visitFirstUnread = const Value.absent(),
+    this.markReadOnScroll = const Value.absent(),
     this.rowid = const Value.absent(),
   });
   static Insertable<GlobalSettingsData> custom({
     Expression<String>? themeSetting,
     Expression<String>? browserPreference,
     Expression<String>? visitFirstUnread,
+    Expression<String>? markReadOnScroll,
     Expression<int>? rowid,
   }) {
     return RawValuesInsertable({
       if (themeSetting != null) 'theme_setting': themeSetting,
       if (browserPreference != null) 'browser_preference': browserPreference,
       if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread,
+      if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll,
       if (rowid != null) 'rowid': rowid,
     });
   }
@@ -283,12 +348,14 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
     Value<ThemeSetting?>? themeSetting,
     Value<BrowserPreference?>? browserPreference,
     Value<VisitFirstUnreadSetting?>? visitFirstUnread,
+    Value<MarkReadOnScrollSetting?>? markReadOnScroll,
     Value<int>? rowid,
   }) {
     return GlobalSettingsCompanion(
       themeSetting: themeSetting ?? this.themeSetting,
       browserPreference: browserPreference ?? this.browserPreference,
       visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread,
+      markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll,
       rowid: rowid ?? this.rowid,
     );
   }
@@ -315,6 +382,13 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
         ),
       );
     }
+    if (markReadOnScroll.present) {
+      map['mark_read_on_scroll'] = Variable<String>(
+        $GlobalSettingsTable.$convertermarkReadOnScrolln.toSql(
+          markReadOnScroll.value,
+        ),
+      );
+    }
     if (rowid.present) {
       map['rowid'] = Variable<int>(rowid.value);
     }
@@ -327,6 +401,7 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
           ..write('themeSetting: $themeSetting, ')
           ..write('browserPreference: $browserPreference, ')
           ..write('visitFirstUnread: $visitFirstUnread, ')
+          ..write('markReadOnScroll: $markReadOnScroll, ')
           ..write('rowid: $rowid')
           ..write(')'))
         .toString();
@@ -1172,6 +1247,7 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder =
       Value<ThemeSetting?> themeSetting,
       Value<BrowserPreference?> browserPreference,
       Value<VisitFirstUnreadSetting?> visitFirstUnread,
+      Value<MarkReadOnScrollSetting?> markReadOnScroll,
       Value<int> rowid,
     });
 typedef $$GlobalSettingsTableUpdateCompanionBuilder =
@@ -1179,6 +1255,7 @@ typedef $$GlobalSettingsTableUpdateCompanionBuilder =
       Value<ThemeSetting?> themeSetting,
       Value<BrowserPreference?> browserPreference,
       Value<VisitFirstUnreadSetting?> visitFirstUnread,
+      Value<MarkReadOnScrollSetting?> markReadOnScroll,
       Value<int> rowid,
     });

@@ -1212,6 +1289,16 @@ class $$GlobalSettingsTableFilterComposer
     column: $table.visitFirstUnread,
     builder: (column) => ColumnWithTypeConverterFilters(column),
   );
+
+  ColumnWithTypeConverterFilters<
+    MarkReadOnScrollSetting?,
+    MarkReadOnScrollSetting,
+    String
+  >
+  get markReadOnScroll => $composableBuilder(
+    column: $table.markReadOnScroll,
+    builder: (column) => ColumnWithTypeConverterFilters(column),
+  );
 }

 class $$GlobalSettingsTableOrderingComposer
@@ -1237,6 +1324,11 @@ class $$GlobalSettingsTableOrderingComposer
     column: $table.visitFirstUnread,
     builder: (column) => ColumnOrderings(column),
   );
+
+  ColumnOrderings<String> get markReadOnScroll => $composableBuilder(
+    column: $table.markReadOnScroll,
+    builder: (column) => ColumnOrderings(column),
+  );
 }

 class $$GlobalSettingsTableAnnotationComposer
@@ -1265,6 +1357,12 @@ class $$GlobalSettingsTableAnnotationComposer
     column: $table.visitFirstUnread,
     builder: (column) => column,
   );
+
+  GeneratedColumnWithTypeConverter<MarkReadOnScrollSetting?, String>
+  get markReadOnScroll => $composableBuilder(
+    column: $table.markReadOnScroll,
+    builder: (column) => column,
+  );
 }

 class $$GlobalSettingsTableTableManager
@@ -1309,11 +1407,14 @@ class $$GlobalSettingsTableTableManager
                     const Value.absent(),
                 Value<VisitFirstUnreadSetting?> visitFirstUnread =
                     const Value.absent(),
+                Value<MarkReadOnScrollSetting?> markReadOnScroll =
+                    const Value.absent(),
                 Value<int> rowid = const Value.absent(),
               }) => GlobalSettingsCompanion(
                 themeSetting: themeSetting,
                 browserPreference: browserPreference,
                 visitFirstUnread: visitFirstUnread,
+                markReadOnScroll: markReadOnScroll,
                 rowid: rowid,
               ),
           createCompanionCallback:
@@ -1323,11 +1424,14 @@ class $$GlobalSettingsTableTableManager
                     const Value.absent(),
                 Value<VisitFirstUnreadSetting?> visitFirstUnread =
                     const Value.absent(),
+                Value<MarkReadOnScrollSetting?> markReadOnScroll =
+                    const Value.absent(),
                 Value<int> rowid = const Value.absent(),
               }) => GlobalSettingsCompanion.insert(
                 themeSetting: themeSetting,
                 browserPreference: browserPreference,
                 visitFirstUnread: visitFirstUnread,
+                markReadOnScroll: markReadOnScroll,
                 rowid: rowid,
               ),
           withReferenceMapper: (p0) => p0
diff --git a/lib/model/message.dart b/lib/model/message.dart
index 1dfe421368..9e9e45ca6a 100644
--- a/lib/model/message.dart
+++ b/lib/model/message.dart
@@ -1,10 +1,11 @@
 import 'dart:async';
-import 'dart:collection';
 import 'dart:convert';

+import 'package:collection/collection.dart';
 import 'package:crypto/crypto.dart';
 import 'package:flutter/foundation.dart';

+import '../api/exception.dart';
 import '../api/model/events.dart';
 import '../api/model/model.dart';
 import '../api/route/messages.dart';
@@ -28,6 +29,8 @@ mixin MessageStore {
   void registerMessageList(MessageListView view);
   void unregisterMessageList(MessageListView view);

+  void markReadFromScroll(Iterable<int> messageIds);
+
   Future<void> sendMessage({
     required MessageDestination destination,
     required String content,
@@ -180,6 +183,67 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes
     _disposed = true;
   }

+  static const _markReadOnScrollBatchSize = 1000;
+  static const _markReadOnScrollDebounceDuration = Duration(milliseconds: 500);
+  final _markReadOnScrollQueue = _MarkReadOnScrollQueue();
+  bool _markReadOnScrollBusy = false;
+
+  /// Returns true on success, false on failure.
+  Future<bool> _sendMarkReadOnScrollRequest(List<int> toSend) async {
+    assert(toSend.isNotEmpty);
+
+    // TODO(#1581) mark as read locally for latency compensation
+    //   (in Unreads and on the message objects)
+    try {
+      await updateMessageFlags(connection,
+        messages: toSend,
+        op: UpdateMessageFlagsOp.add,
+        flag: MessageFlag.read);
+    } on ApiRequestException {
+      // TODO(#1581) un-mark as read locally?
+      return false;
+    }
+    return true;
+  }
+
+  @override
+  void markReadFromScroll(Iterable<int> messageIds) async {
+    assert(!_disposed);
+    _markReadOnScrollQueue.addAll(messageIds);
+    if (_markReadOnScrollBusy) return;
+
+    _markReadOnScrollBusy = true;
+    try {
+      do {
+        final toSend = <int>[];
+        int numFromQueue = 0;
+        for (final messageId in _markReadOnScrollQueue.iterable) {
+          if (toSend.length == _markReadOnScrollBatchSize) {
+            break;
+          }
+          final message = messages[messageId];
+          if (message != null && !message.flags.contains(MessageFlag.read)) {
+            toSend.add(message.id);
+          }
+          numFromQueue++;
+        }
+
+        if (toSend.isEmpty || await _sendMarkReadOnScrollRequest(toSend)) {
+          if (_disposed) return;
+          _markReadOnScrollQueue.removeFirstN(numFromQueue);
+        }
+        if (_disposed) return;
+
+        await Future<void>.delayed(_markReadOnScrollDebounceDuration);
+        if (_disposed) return;
+      } while (_markReadOnScrollQueue.isNotEmpty);
+    } finally {
+      if (!_disposed) {
+        _markReadOnScrollBusy = false;
+      }
+    }
+  }
+
   @override
   Future<void> sendMessage({required MessageDestination destination, required String content}) {
     assert(!_disposed);
@@ -517,6 +581,34 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes
   }
 }

+class _MarkReadOnScrollQueue {
+  _MarkReadOnScrollQueue();
+
+  bool get isNotEmpty => _queue.isNotEmpty;
+
+  final _set = <int>{};
+  final _queue = QueueList<int>();
+
+  /// Add [messageIds] to the end of the queue,
+  /// if they aren't already in the queue.
+  void addAll(Iterable<int> messageIds) {
+    for (final messageId in messageIds) {
+      if (_set.add(messageId)) {
+        _queue.add(messageId);
+      }
+    }
+  }
+
+  Iterable<int> get iterable => _queue;
+
+  void removeFirstN(int n) {
+    for (int i = 0; i < n; i++) {
+      if (_queue.isEmpty) break;
+      _set.remove(_queue.removeFirst());
+    }
+  }
+}
+
 /// The duration an outbox message stays hidden to the user.
 ///
 /// See [OutboxMessageState.waiting].
diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index a7aff0dcbc..f30d7fac0a 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -222,6 +222,17 @@ mixin _MessageSequence {
     return binarySearchByKey(items, messageId, _compareItemToMessageId);
   }

+  Iterable<Message>? getMessagesRange(int firstMessageId, int lastMessageId) {
+    assert(firstMessageId <= lastMessageId);
+    final firstIndex = _findMessageWithId(firstMessageId);
+    final lastIndex = _findMessageWithId(lastMessageId);
+    if (firstIndex == -1 || lastIndex == -1) {
+      // TODO(log)
+      return null;
+    }
+    return messages.getRange(firstIndex, lastIndex + 1);
+  }
+
   static int _compareItemToMessageId(MessageListItem item, int messageId) {
     switch (item) {
       case MessageListRecipientHeaderItem(:var message):
diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart
index 4fcfc67a06..5712a94fbb 100644
--- a/lib/model/schema_versions.g.dart
+++ b/lib/model/schema_versions.g.dart
@@ -441,6 +441,82 @@ i1.GeneratedColumn<String> _column_13(String aliasedName) =>
       true,
       type: i1.DriftSqlType.string,
     );
+
+final class Schema8 extends i0.VersionedSchema {
+  Schema8({required super.database}) : super(version: 8);
+  @override
+  late final List<i1.DatabaseSchemaEntity> entities = [
+    globalSettings,
+    boolGlobalSettings,
+    accounts,
+  ];
+  late final Shape5 globalSettings = Shape5(
+    source: i0.VersionedTable(
+      entityName: 'global_settings',
+      withoutRowId: false,
+      isStrict: false,
+      tableConstraints: [],
+      columns: [_column_9, _column_10, _column_13, _column_14],
+      attachedDatabase: database,
+    ),
+    alias: null,
+  );
+  late final Shape3 boolGlobalSettings = Shape3(
+    source: i0.VersionedTable(
+      entityName: 'bool_global_settings',
+      withoutRowId: false,
+      isStrict: false,
+      tableConstraints: ['PRIMARY KEY(name)'],
+      columns: [_column_11, _column_12],
+      attachedDatabase: database,
+    ),
+    alias: null,
+  );
+  late final Shape0 accounts = Shape0(
+    source: i0.VersionedTable(
+      entityName: 'accounts',
+      withoutRowId: false,
+      isStrict: false,
+      tableConstraints: [
+        'UNIQUE(realm_url, user_id)',
+        'UNIQUE(realm_url, email)',
+      ],
+      columns: [
+        _column_0,
+        _column_1,
+        _column_2,
+        _column_3,
+        _column_4,
+        _column_5,
+        _column_6,
+        _column_7,
+        _column_8,
+      ],
+      attachedDatabase: database,
+    ),
+    alias: null,
+  );
+}
+
+class Shape5 extends i0.VersionedTable {
+  Shape5({required super.source, required super.alias}) : super.aliased();
+  i1.GeneratedColumn<String> get themeSetting =>
+      columnsByName['theme_setting']! as i1.GeneratedColumn<String>;
+  i1.GeneratedColumn<String> get browserPreference =>
+      columnsByName['browser_preference']! as i1.GeneratedColumn<String>;
+  i1.GeneratedColumn<String> get visitFirstUnread =>
+      columnsByName['visit_first_unread']! as i1.GeneratedColumn<String>;
+  i1.GeneratedColumn<String> get markReadOnScroll =>
+      columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn<String>;
+}
+
+i1.GeneratedColumn<String> _column_14(String aliasedName) =>
+    i1.GeneratedColumn<String>(
+      'mark_read_on_scroll',
+      aliasedName,
+      true,
+      type: i1.DriftSqlType.string,
+    );
 i0.MigrationStepWithVersion migrationSteps({
   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -448,6 +524,7 @@ i0.MigrationStepWithVersion migrationSteps({
   required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
   required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
   required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
+  required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
 }) {
   return (currentVersion, database) async {
     switch (currentVersion) {
@@ -481,6 +558,11 @@ i0.MigrationStepWithVersion migrationSteps({
         final migrator = i1.Migrator(database, schema);
         await from6To7(migrator, schema);
         return 7;
+      case 7:
+        final schema = Schema8(database: database);
+        final migrator = i1.Migrator(database, schema);
+        await from7To8(migrator, schema);
+        return 8;
       default:
         throw ArgumentError.value('Unknown migration from $currentVersion');
     }
@@ -494,6 +576,7 @@ i1.OnUpgrade stepByStep({
   required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
   required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
   required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
+  required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
 }) => i0.VersionedSchema.stepByStepHelper(
   step: migrationSteps(
     from1To2: from1To2,
@@ -502,5 +585,6 @@ i1.OnUpgrade stepByStep({
     from4To5: from4To5,
     from5To6: from5To6,
     from6To7: from6To7,
+    from7To8: from7To8,
   ),
 );
diff --git a/lib/model/settings.dart b/lib/model/settings.dart
index d3393292a6..298980a395 100644
--- a/lib/model/settings.dart
+++ b/lib/model/settings.dart
@@ -67,6 +67,26 @@ enum VisitFirstUnreadSetting {
   static VisitFirstUnreadSetting _default = conversations;
 }

+/// The user's choice of which message-list views should
+/// automatically mark messages as read when scrolling through them.
+///
+/// This can be overridden by local state: for example, if you've just tapped
+/// "Mark as unread from here" the view will stop marking as read automatically,
+/// regardless of this setting.
+enum MarkReadOnScrollSetting {
+  /// All views.
+  always,
+
+  /// Only conversation views.
+  conversations,
+
+  /// No views.
+  never;
+
+  /// The effective value of this setting if the user hasn't set it.
+  static MarkReadOnScrollSetting _default = conversations;
+}
+
 /// A general category of account-independent setting the user might set.
 ///
 /// Different kinds of settings call for different treatment in the UI,
@@ -277,6 +297,33 @@ class GlobalSettingsStore extends ChangeNotifier {
     };
   }

+  /// The user's choice of [MarkReadOnScrollSetting], applying our default.
+  ///
+  /// See also [markReadOnScrollForNarrow] and [setMarkReadOnScroll].
+  MarkReadOnScrollSetting get markReadOnScroll {
+    return _data.markReadOnScroll ?? MarkReadOnScrollSetting._default;
+  }
+
+  /// Set [markReadOnScroll], persistently for future runs of the app.
+  Future<void> setMarkReadOnScroll(MarkReadOnScrollSetting value) async {
+    await _update(GlobalSettingsCompanion(markReadOnScroll: Value(value)));
+  }
+
+  /// The value that [markReadOnScroll] works out to for the given narrow.
+  bool markReadOnScrollForNarrow(Narrow narrow) {
+    return switch (markReadOnScroll) {
+      MarkReadOnScrollSetting.always => true,
+      MarkReadOnScrollSetting.never => false,
+      MarkReadOnScrollSetting.conversations => switch (narrow) {
+        TopicNarrow() || DmNarrow()
+          => true,
+        CombinedFeedNarrow() || ChannelNarrow()
+        || MentionsNarrow() || StarredMessagesNarrow()
+          => false,
+      },
+    };
+  }
+
   /// The user's choice of the given bool-valued setting, or our default for it.
   ///
   /// See also [setBool].
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 5171807a8e..8fad731f5c 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -758,6 +758,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
   void unregisterMessageList(MessageListView view) =>
     _messages.unregisterMessageList(view);
   @override
+  void markReadFromScroll(Iterable<int> messageIds) =>
+    _messages.markReadFromScroll(messageIds);
+  @override
   Future<void> sendMessage({required MessageDestination destination, required String content}) {
     assert(!_disposed);
     return _messages.sendMessage(destination: destination, content: content);
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 5c29b590de..a78ba323c7 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -896,9 +896,11 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton {
   }

   @override void onPressed() async {
-    final narrow = findMessageListPage().narrow;
+    final messageListPage = findMessageListPage();
     unawaited(ZulipAction.markNarrowAsUnreadFromMessage(pageContext,
-      message, narrow));
+      message, messageListPage.narrow));
+    // TODO should we alert the user about this change somehow? A snackbar?
+    messageListPage.markReadOnScroll = false;
   }
 }

diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 14c33ad5fd..75c8b5cee0 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -5,6 +5,7 @@ import 'package:intl/intl.dart' hide TextDirection;

 import '../api/model/model.dart';
 import '../generated/l10n/zulip_localizations.dart';
+import '../model/database.dart';
 import '../model/message.dart';
 import '../model/message_list.dart';
 import '../model/narrow.dart';
@@ -139,6 +140,14 @@ abstract class MessageListPageState {
   ///
   /// This is null if [MessageList] has not mounted yet.
   MessageListView? get model;
+
+  /// This view's decision whether to mark read on scroll,
+  /// overriding [GlobalSettings.markReadOnScroll].
+  ///
+  /// For example, this is set to false after pressing
+  /// "Mark as unread from here" in the message action sheet.
+  bool? get markReadOnScroll;
+  set markReadOnScroll(bool? value);
 }

 class MessageListPage extends StatefulWidget {
@@ -172,6 +181,32 @@ class MessageListPage extends StatefulWidget {

   @override
   State<MessageListPage> createState() => _MessageListPageState();
+
+  /// In debug mode, controls whether mark-read-on-scroll is enabled,
+  /// overriding [GlobalSettings.markReadOnScroll]
+  /// and [MessageListPageState.markReadOnScroll].
+  ///
+  /// Outside of debug mode, this is always true and the setter has no effect.
+  static bool get debugEnableMarkReadOnScroll {
+    bool result = true;
+    assert(() {
+      result = _debugEnableMarkReadOnScroll;
+      return true;
+    }());
+    return result;
+  }
+  static bool _debugEnableMarkReadOnScroll = true;
+  static set debugEnableMarkReadOnScroll(bool value) {
+    assert(() {
+      _debugEnableMarkReadOnScroll = value;
+      return true;
+    }());
+  }
+
+  @visibleForTesting
+  static void debugReset() {
+    _debugEnableMarkReadOnScroll = true;
+  }
 }

 class _MessageListPageState extends State<MessageListPage> implements MessageListPageState {
@@ -186,6 +221,16 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
   MessageListView? get model => _messageListKey.currentState?.model;
   final GlobalKey<_MessageListState> _messageListKey = GlobalKey();

+  @override
+  bool? get markReadOnScroll => _markReadOnScroll;
+  bool? _markReadOnScroll;
+  @override
+  set markReadOnScroll(bool? value) {
+    setState(() {
+      _markReadOnScroll = value;
+    });
+  }
+
   @override
   void initState() {
     super.initState();
@@ -298,6 +343,7 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
                   narrow: narrow,
                   initAnchor: initAnchor,
                   onNarrowChanged: _narrowChanged,
+                  markReadOnScroll: markReadOnScroll,
                 ))),
             if (ComposeBox.hasComposeBox(narrow))
               ComposeBox(key: _composeBoxKey, narrow: narrow)
@@ -503,17 +549,21 @@ class MessageList extends StatefulWidget {
     required this.narrow,
     required this.initAnchor,
     required this.onNarrowChanged,
+    required this.markReadOnScroll,
   });

   final Narrow narrow;
   final Anchor initAnchor;
   final void Function(Narrow newNarrow) onNarrowChanged;
+  final bool? markReadOnScroll;

   @override
   State<StatefulWidget> createState() => _MessageListState();
 }

 class _MessageListState extends State<MessageList> with PerAccountStoreAwareStateMixin<MessageList> {
+  final GlobalKey _scrollViewKey = GlobalKey();
+
   MessageListView get model => _model!;
   MessageListView? _model;

@@ -552,6 +602,17 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
   bool _prevFetched = false;

   void _modelChanged() {
+    // When you're scrolling quickly, our mark-as-read requests include the
+    // messages *between* _messagesRecentlyInViewport and the messages currently
+    // in view, so that messages don't get left out because you were scrolling
+    // so fast that they never rendered onscreen.
+    //
+    // Here, the onscreen messages might be totally different,
+    // and not because of scrolling; e.g. because the narrow changed.
+    // Avoid "filling in" a mark-as-read request with totally wrong messages,
+    // by forgetting the old range.
+    _messagesRecentlyInViewport = null;
+
     if (model.narrow != widget.narrow) {
       // Either:
       // - A message move event occurred, where propagate mode is
@@ -576,7 +637,120 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
     _prevFetched = model.fetched;
   }

+  /// Find the range of message IDs on screen, as a (first, last) tuple,
+  /// or null if no messages are onscreen.
+  ///
+  /// A message is considered onscreen if its bottom edge is in the viewport.
+  ///
+  /// Ignores outbox messages.
+  (int, int)? _findMessagesInViewport() {
+    final scrollViewElement = _scrollViewKey.currentContext as Element;
+    final scrollViewRenderObject = scrollViewElement.renderObject as RenderBox;
+
+    int? first;
+    int? last;
+    void visit(Element element) {
+      final widget = element.widget;
+      switch (widget) {
+        case RecipientHeader():
+        case DateSeparator():
+        case MarkAsReadWidget():
+          // MessageItems won't be descendants of these
+          return;
+
+        case MessageItem(item: MessageListOutboxMessageItem()):
+          return; // ignore outbox
+
+        case MessageItem(item: MessageListMessageItem(:final message)):
+          final isInViewport = _isMessageItemInViewport(
+            element, scrollViewRenderObject: scrollViewRenderObject);
+          if (isInViewport) {
+            if (first == null) {
+              assert(last == null);
+              first = message.id;
+              last = message.id;
+              return;
+            }
+            if (message.id < first!) {
+              first = message.id;
+            }
+            if (last! < message.id) {
+              last = message.id;
+            }
+          }
+          return; // no need to look for more MessageItems inside this one
+
+        default:
+          element.visitChildElements(visit);
+      }
+    }
+    scrollViewElement.visitChildElements(visit);
+
+    if (first == null) {
+      assert(last == null);
+      return null;
+    }
+    return (first!, last!);
+  }
+
+  bool _isMessageItemInViewport(
+    Element element, {
+    required RenderBox scrollViewRenderObject,
+  }) {
+    assert(element.widget is MessageItem
+      && (element.widget as MessageItem).item is MessageListMessageItem);
+    final viewportHeight = scrollViewRenderObject.size.height;
+
+    final messageRenderObject = element.renderObject as RenderBox;
+
+    final messageBottom = messageRenderObject.localToGlobal(
+      Offset(0, messageRenderObject.size.height),
+      ancestor: scrollViewRenderObject).dy;
+
+    return 0 < messageBottom && messageBottom <= viewportHeight;
+  }
+
+  (int, int)? _messagesRecentlyInViewport;
+
+  void _markReadFromScroll() {
+    final currentRange = _findMessagesInViewport();
+    if (currentRange == null) return;
+
+    final (currentFirst, currentLast) = currentRange;
+    final (prevFirst, prevLast) = _messagesRecentlyInViewport ?? (null, null);
+
+    // ("Hull" as in the "convex hull" around the old and new ranges.)
+    final firstOfHull = switch ((prevFirst, currentFirst)) {
+      (int previous, int current) => previous < current ? previous : current,
+      (           _, int current) => current,
+    };
+
+    final lastOfHull = switch ((prevLast, currentLast)) {
+      (int previous, int current) => previous > current ? previous : current,
+      (           _, int current) => current,
+    };
+
+    final sublist = model.getMessagesRange(firstOfHull, lastOfHull);
+    if (sublist == null) {
+      _messagesRecentlyInViewport = null;
+      return;
+    }
+    model.store.markReadFromScroll(sublist.map((message) => message.id));
+
+    _messagesRecentlyInViewport = currentRange;
+  }
+
+  bool _effectiveMarkReadOnScroll() {
+    if (!MessageListPage.debugEnableMarkReadOnScroll) return false;
+    return widget.markReadOnScroll
+      ?? GlobalStoreWidget.settingsOf(context).markReadOnScrollForNarrow(widget.narrow);
+  }
+
   void _handleScrollMetrics(ScrollMetrics scrollMetrics) {
+    if (_effectiveMarkReadOnScroll()) {
+      _markReadFromScroll();
+    }
+
     if (scrollMetrics.extentAfter == 0) {
       _scrollToBottomVisible.value = false;
     } else {
@@ -745,6 +919,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
     }

     return MessageListScrollView(
+      key: _scrollViewKey,
+
       // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
       //   similar) if that is ever offered:
       //     https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849
diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart
index 449be11313..394415a8be 100644
--- a/lib/widgets/settings.dart
+++ b/lib/widgets/settings.dart
@@ -24,6 +24,7 @@ class SettingsPage extends StatelessWidget {
         const _ThemeSetting(),
         const _BrowserPreferenceSetting(),
         const _VisitFirstUnreadSetting(),
+        const _MarkReadOnScrollSetting(),
         if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty)
           ListTile(
             title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle),
@@ -150,6 +151,86 @@ class VisitFirstUnreadSettingPage extends StatelessWidget {
   }
 }

+class _MarkReadOnScrollSetting extends StatelessWidget {
+  const _MarkReadOnScrollSetting();
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    final globalSettings = GlobalStoreWidget.settingsOf(context);
+    return ListTile(
+      title: Text(zulipLocalizations.markReadOnScrollSettingTitle),
+      subtitle: Text(MarkReadOnScrollSettingPage._valueDisplayName(
+        globalSettings.markReadOnScroll, zulipLocalizations: zulipLocalizations)),
+      onTap: () => Navigator.push(context,
+        MarkReadOnScrollSettingPage.buildRoute()));
+  }
+}
+
+class MarkReadOnScrollSettingPage extends StatelessWidget {
+  const MarkReadOnScrollSettingPage({super.key});
+
+  static WidgetRoute<void> buildRoute() {
+    return MaterialWidgetRoute(page: const MarkReadOnScrollSettingPage());
+  }
+
+  static String _valueDisplayName(MarkReadOnScrollSetting value, {
+    required ZulipLocalizations zulipLocalizations,
+  }) {
+    return switch (value) {
+      MarkReadOnScrollSetting.always =>
+        zulipLocalizations.markReadOnScrollSettingAlways,
+      MarkReadOnScrollSetting.conversations =>
+        zulipLocalizations.markReadOnScrollSettingConversations,
+      MarkReadOnScrollSetting.never =>
+        zulipLocalizations.markReadOnScrollSettingNever,
+    };
+  }
+
+  static String? _valueDescription(MarkReadOnScrollSetting value, {
+    required ZulipLocalizations zulipLocalizations,
+  }) {
+    return switch (value) {
+      MarkReadOnScrollSetting.always => null,
+      MarkReadOnScrollSetting.conversations =>
+        zulipLocalizations.markReadOnScrollSettingConversationsDescription,
+      MarkReadOnScrollSetting.never => null,
+    };
+  }
+
+  void _handleChange(BuildContext context, MarkReadOnScrollSetting? value) {
+    if (value == null) return; // TODO(log); can this actually happen? how?
+    final globalSettings = GlobalStoreWidget.settingsOf(context);
+    globalSettings.setMarkReadOnScroll(value);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    final globalSettings = GlobalStoreWidget.settingsOf(context);
+    return Scaffold(
+      appBar: AppBar(title: Text(zulipLocalizations.markReadOnScrollSettingTitle)),
+      body: Column(children: [
+        ListTile(title: Text(zulipLocalizations.markReadOnScrollSettingDescription)),
+        for (final value in MarkReadOnScrollSetting.values)
+          RadioListTile.adaptive(
+            title: Text(_valueDisplayName(value,
+              zulipLocalizations: zulipLocalizations)),
+            subtitle: () {
+              final result = _valueDescription(value,
+                zulipLocalizations: zulipLocalizations);
+              return result == null ? null : Text(result);
+            }(),
+            value: value,
+            // TODO(#1545) stop using the deprecated members
+            // ignore: deprecated_member_use
+            groupValue: globalSettings.markReadOnScroll,
+            // ignore: deprecated_member_use
+            onChanged: (newValue) => _handleChange(context, newValue)),
+      ]));
+  }
+}
+
 class ExperimentalFeaturesPage extends StatelessWidget {
   const ExperimentalFeaturesPage({super.key});


anchors 10/n: Open /near/ links at message

Fixes #82.

This is the next round after #1515 — and completes #82, as well as bringing us close to #80. (These changes were previously sent as part of #1517.)

This branch is a draft only because of a lack of tests. The changes it does have are ready for review.

This doesn't implement the other aspect of the behavior of /near/ links, i.e. to follow the message if it's been moved to another conversation. That's tracked as #683.

Open a message list in the middle of history, when requested
When the user takes an action that should navigate to a particular "anchor" message (like through #73 internal links, #76 deep links, or a notification), we should take them straight to that message, rather than to the latest message or first unread message in the narrow.

This is a feature the zulip-mobile RN app lacks: https://github.com/zulip/zulip-mobile/issues/3604 . (That's for internal links, but the app doesn't have it for notifications either — see `narrowToNotification` which passes no anchor to `doNarrow` — and doesn't have deep links.)

There are two parts to this:
* Fetch the messages around the desired anchor, rather than around the latest message or the first unread.
* Control the scroll position so that we show the desired anchor message.

This has a lot in common with:
* #80

This issue has fewer prerequisites technically than #80 — no need for #79 first. On the other hand it has more prerequisites before there's a UX context where it makes sense: it'd need either #73, #76, or notifications, all of which are more complex than #79.

Started working on this.

https://chat.zulip.org/#narrow/channel/516-mobile-dev-help/topic/Control.20scroll.20position.20on.20new.20and.20newly-fetched.20messages

https://github.com/user-attachments/assets/a379256e-3208-48b5-960c-7740a06fe497

https://github.com/user-attachments/assets/9feec67c-380e-4e92-9631-37a58ec8d8ab

This came up in beta feedback: [#mobile > Flutter Beta: Some Feedback @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Flutter.20Beta.3A.20Some.20Feedback/near/2153404)

flutter test test/model/internal_link_test.dart test/model/message_list_test.dart test/widgets/content_test.dart test/widgets/message_list_test.dart --reporter expanded 2>&1

diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart
index 749f60698c..a552f1679f 100644
--- a/lib/model/internal_link.dart
+++ b/lib/model/internal_link.dart
@@ -109,22 +109,43 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
   return result;
 }

-/// A [Narrow] from a given URL, on `store`'s realm.
+/// The result of parsing some URL within a Zulip realm,
+/// when the URL corresponds to some page in this app.
+sealed class InternalLink {
+  InternalLink({required this.realmUrl});
+
+  final Uri realmUrl;
+}
+
+/// The result of parsing some URL that points to a narrow on a Zulip realm,
+/// when the narrow is of a type that this app understands.
+class NarrowLink extends InternalLink {
+  NarrowLink(this.narrow, this.nearMessageId, {required super.realmUrl});
+
+  final Narrow narrow;
+  final int? nearMessageId;
+}
+
+/// Try to parse the given URL as a page in this app, on `store`'s realm.
 ///
 /// `url` must already be a result from [PerAccountStore.tryResolveUrl]
 /// on `store`.
 ///
-/// Returns `null` if any of the operator/operand pairs are invalid.
+/// Returns null if the URL isn't on this realm,
+/// or isn't a valid Zulip URL,
+/// or isn't currently supported as leading to a page in this app.
 ///
+/// In particular this will return null if `url` is a `/#narrow/…` URL
+/// and any of the operator/operand pairs are invalid.
 /// Since narrow links can combine operators in ways our [Narrow] type can't
 /// represent, this can also return null for valid narrow links.
 ///
 /// This can also return null for some valid narrow links that our Narrow
 /// type *could* accurately represent. We should try to understand these
-/// better, but some kinds will be rare, even unheard-of:
+/// better, but some kinds will be rare, even unheard-of.  For example:
 ///   #narrow/stream/1-announce/stream/1-announce (duplicated operator)
 // TODO(#252): handle all valid narrow links, returning a search narrow
-Narrow? parseInternalLink(Uri url, PerAccountStore store) {
+InternalLink? parseInternalLink(Uri url, PerAccountStore store) {
   if (!_isInternalLink(url, store.realmUrl)) return null;

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

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

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

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

-      case _NarrowOperator.near: // TODO(#82): support for near
-        continue;
+      case _NarrowOperator.near:
+        if (nearMessageId != null) return null;
+        final messageId = int.tryParse(operand, radix: 10);
+        if (messageId == null) return null;
+        nearMessageId = messageId;

       case _NarrowOperator.unknown:
         return null;
     }
   }

+  final Narrow? narrow;
   if (isElementOperands.isNotEmpty) {
     if (streamElement != null || topicElement != null || dmElement != null || withElement != null) {
       return null;
@@ -216,9 +242,9 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
     if (isElementOperands.length > 1) return null;
     switch (isElementOperands.single) {
       case IsOperand.mentioned:
-        return const MentionsNarrow();
+        narrow = const MentionsNarrow();
       case IsOperand.starred:
-        return const StarredMessagesNarrow();
+        narrow = const StarredMessagesNarrow();
       case IsOperand.dm:
       case IsOperand.private:
       case IsOperand.alerted:
@@ -230,17 +256,20 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
     }
   } else if (dmElement != null) {
     if (streamElement != null || topicElement != null || withElement != null) return null;
-    return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
+    narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
   } else if (streamElement != null) {
     final streamId = streamElement.operand;
     if (topicElement != null) {
-      return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
+      narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
     } else {
       if (withElement != null) return null;
-      return ChannelNarrow(streamId);
+      narrow = ChannelNarrow(streamId);
     }
+  } else {
+    return null;
   }
-  return null;
+
+  return NarrowLink(narrow, nearMessageId, realmUrl: store.realmUrl);
 }

 @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index 2617f18b68..458478f248 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -479,7 +479,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
   /// which might be made internally by this class in order to
   /// fetch the messages from scratch, e.g. after certain events.
   Anchor get anchor => _anchor;
-  final Anchor _anchor;
+  Anchor _anchor;

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

+  /// Reset this view to start from the newest messages.
+  ///
+  /// This will set [anchor] to [AnchorCode.newest],
+  /// and cause messages to be re-fetched from scratch.
+  void jumpToEnd() {
+    assert(fetched);
+    assert(!haveNewest);
+    assert(anchor != AnchorCode.newest);
+    _anchor = AnchorCode.newest;
+    _reset();
+    notifyListeners();
+    fetchInitial();
+  }
+
   /// Add [outboxMessage] if it belongs to the view.
   void addOutboxMessage(OutboxMessage outboxMessage) {
     // TODO(#1441) implement this
diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart
index 081a2cf633..6e2585e135 100644
--- a/lib/notifications/display.dart
+++ b/lib/notifications/display.dart
@@ -513,7 +513,7 @@ class NotificationDisplayManager {

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

diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 62801ab867..44411434fd 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1536,15 +1536,18 @@ void _launchUrl(BuildContext context, String urlString) async {
     return;
   }

-  final internalNarrow = parseInternalLink(url, store);
-  if (internalNarrow != null) {
-    unawaited(Navigator.push(context,
-      MessageListPage.buildRoute(context: context,
-        narrow: internalNarrow)));
-    return;
+  final internalLink = parseInternalLink(url, store);
+  assert(internalLink == null || internalLink.realmUrl == store.realmUrl);
+  switch (internalLink) {
+    case NarrowLink():
+      unawaited(Navigator.push(context,
+        MessageListPage.buildRoute(context: context,
+          narrow: internalLink.narrow,
+          initAnchorMessageId: internalLink.nearMessageId)));
+
+    case null:
+      await PlatformActions.launchUrl(context, url);
   }
-
-  await PlatformActions.launchUrl(context, url);
 }

 /// Like [Image.network], but includes [authHeader] if [src] is on-realm.
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index aa9684b7f2..e1c24249c5 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -141,12 +141,17 @@ abstract class MessageListPageState {
 }

 class MessageListPage extends StatefulWidget {
-  const MessageListPage({super.key, required this.initNarrow});
+  const MessageListPage({
+    super.key,
+    required this.initNarrow,
+    this.initAnchorMessageId,
+  });

   static AccountRoute<void> buildRoute({int? accountId, BuildContext? context,
-      required Narrow narrow}) {
+      required Narrow narrow, int? initAnchorMessageId}) {
     return MaterialAccountWidgetRoute(accountId: accountId, context: context,
-      page: MessageListPage(initNarrow: narrow));
+      page: MessageListPage(
+        initNarrow: narrow, initAnchorMessageId: initAnchorMessageId));
   }

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

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

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

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

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

   final Narrow narrow;
+  final Anchor initAnchor;
   final void Function(Narrow newNarrow) onNarrowChanged;

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

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

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

-  void _initModel(PerAccountStore store) {
-    // TODO(#82): get anchor as page/route argument, instead of using newest
-    // TODO(#80): default to anchor firstUnread, instead of newest
-    final anchor = AnchorCode.newest;
+  void _initModel(PerAccountStore store, Anchor anchor) {
     _model = MessageListView.init(store: store,
       narrow: widget.narrow, anchor: anchor);
     model.addListener(_modelChanged);
@@ -537,6 +554,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
       //   redirected us to the new location of the operand message ID.
       widget.onNarrowChanged(model.narrow);
     }
+    // TODO when model reset, reset scroll
     setState(() {
       // The actual state lives in the [MessageListView] model.
       // This method was called because that just changed.
@@ -568,6 +586,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
       //   still not yet updated to account for the newly-added messages.
       model.fetchOlder();
     }
+    if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {
+      model.fetchNewer();
+    }
   }

   void _scrollChanged() {
@@ -618,6 +639,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
                   //   MessageList's dartdoc.
                   child: SafeArea(
                     child: ScrollToBottomButton(
+                      model: model,
                       scrollController: scrollController,
                       visible: _scrollToBottomVisible))),
               ])))));
@@ -753,13 +775,21 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
   }

   Widget _buildEndCap() {
-    return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
-      TypingStatusWidget(narrow: widget.narrow),
-      MarkAsReadWidget(narrow: widget.narrow),
-      // To reinforce that the end of the feed has been reached:
-      //   https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
-      const SizedBox(height: 36),
-    ]);
+    if (model.haveNewest) {
+      return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
+        TypingStatusWidget(narrow: widget.narrow),
+        // TODO perhaps offer mark-as-read even when not done fetching?
+        MarkAsReadWidget(narrow: widget.narrow),
+        // To reinforce that the end of the feed has been reached:
+        //   https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
+        const SizedBox(height: 36),
+      ]);
+    } else if (model.busyFetchingMore) {
+      // See [_buildStartCap] for why this condition shows a loading indicator.
+      return const _MessageListLoadingMore();
+    } else {
+      return SizedBox.shrink();
+    }
   }

   Widget _buildItem(MessageListItem data) {
@@ -809,13 +839,40 @@ class _MessageListLoadingMore extends StatelessWidget {
 }

 class ScrollToBottomButton extends StatelessWidget {
-  const ScrollToBottomButton({super.key, required this.scrollController, required this.visible});
+  const ScrollToBottomButton({
+    super.key,
+    required this.model,
+    required this.scrollController,
+    required this.visible,
+  });

-  final ValueNotifier<bool> visible;
+  final MessageListView model;
   final MessageListScrollController scrollController;
+  final ValueNotifier<bool> visible;

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

   @override

diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart
index 611cc3ece0..824def8cc2 100644
--- a/test/model/internal_link_test.dart
+++ b/test/model/internal_link_test.dart
@@ -160,7 +160,14 @@ void main() {
         test(urlString, () async {
           final store = await setupStore(realmUrl: realmUrl, streams: streams, users: users);
           final url = store.tryResolveUrl(urlString)!;
-          check(parseInternalLink(url, store)).equals(expected);
+          final result = parseInternalLink(url, store);
+          if (expected == null) {
+            check(result).isNull();
+          } else {
+            check(result).isA<NarrowLink>()
+              ..realmUrl.equals(realmUrl)
+              ..narrow.equals(expected);
+          }
         });
       }
     }
@@ -258,6 +265,9 @@ void main() {
           final url = store.tryResolveUrl(urlString)!;
           final result = parseInternalLink(url, store);
           check(result != null).equals(expected);
+          if (result != null) {
+            check(result).realmUrl.equals(realmUrl);
+          }
         });
       }
     }
@@ -370,6 +380,8 @@ void main() {
       }
     });

+    // TODO(#1570): test parsing /near/ operator
+
     group('unexpected link shapes are rejected', () {
       final testCases = [
         ('/#narrow/stream/name/topic/',           null), // missing operand
@@ -564,3 +576,11 @@ void main() {
     });
   });
 }
+
+extension InternalLinkChecks on Subject<InternalLink> {
+  Subject<Uri> get realmUrl => has((x) => x.realmUrl, 'realmUrl');
+}
+
+extension NarrowLinkChecks on Subject<NarrowLink> {
+  Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
+}
diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart
index d58de664d8..0eb30c1cbb 100644
--- a/test/model/message_list_test.dart
+++ b/test/model/message_list_test.dart
@@ -566,6 +566,8 @@ void main() {
     });
   });

+  // TODO(#1569): test jumpToEnd
+
   group('MessageEvent', () {
     test('in narrow', () async {
       final stream = eg.stream();
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index a788225aac..b5150a54ee 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -1032,6 +1032,8 @@ void main() {
         .page.isA<MessageListPage>().initNarrow.equals(const ChannelNarrow(1));
     });

+    // TODO(#1570): test links with /near/ go to the specific message
+
     testWidgets('invalid internal links are opened in browser', (tester) async {
       // Link is invalid due to `topic` operator missing an operand.
       final pushedRoutes = await prepare(tester,
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index 3b3b01b323..c8928a13ad 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -59,6 +59,7 @@ void main() {
     bool foundOldest = true,
     int? messageCount,
     List<Message>? messages,
+    GetMessagesResult? fetchResult,
     List<ZulipStream>? streams,
     List<User>? users,
     List<Subscription>? subscriptions,
@@ -83,12 +84,17 @@ void main() {
     // prepare message list data
     await store.addUser(eg.selfUser);
     await store.addUsers(users ?? []);
-    assert((messageCount == null) != (messages == null));
-    messages ??= List.generate(messageCount!, (index) {
-      return eg.streamMessage(sender: eg.selfUser);
-    });
-    connection.prepare(json:
-      eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson());
+    if (fetchResult != null) {
+      assert(foundOldest && messageCount == null && messages == null);
+    } else {
+      assert((messageCount == null) != (messages == null));
+      messages ??= List.generate(messageCount!, (index) {
+        return eg.streamMessage(sender: eg.selfUser);
+      });
+      fetchResult = eg.newestGetMessagesResult(
+        foundOldest: foundOldest, messages: messages);
+    }
+    connection.prepare(json: fetchResult.toJson());

     await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id,
       skipAssertAccountExists: skipAssertAccountExists,
@@ -368,6 +374,10 @@ void main() {
   });

   group('fetch initial batch of messages', () {
+    // TODO(#1569): test effect of initAnchorMessageId
+    // TODO(#1569): test that after jumpToEnd, then new store causing new fetch,
+    //   new post-jump anchor prevails over initAnchorMessageId
+
     group('topic permalink', () {
       final someStream = eg.stream();
       const someTopic = 'some topic';
@@ -445,6 +455,10 @@ void main() {
   });

   group('fetch older messages on scroll', () {
+    // TODO(#1569): test fetch newer messages on scroll, too;
+    //   in particular test it happens even when near top as well as bottom
+    //   (because may have haveOldest true but haveNewest false)
+
     int? itemCount(WidgetTester tester) =>
       findScrollView(tester).semanticChildCount;

@@ -656,6 +670,8 @@ void main() {
       check(isButtonVisible(tester)).equals(false);
     });

+    // TODO(#1569): test choice of jumpToEnd vs. scrollToEnd
+
     testWidgets('scrolls at reasonable, constant speed', (tester) async {
       const maxSpeed = 8000.0;
       const distance = 40000.0;
@@ -692,6 +708,63 @@ void main() {
     });
   });

+  // TODO test markers at start of list (`_buildStartCap`)
+
+  group('markers at end of list', () {
+    final findLoadingIndicator = find.byType(CircularProgressIndicator);
+
+    testWidgets('spacer when have newest', (tester) async {
+      final messages = List.generate(10,
+        (i) => eg.streamMessage(content: '
message $i
'));
+      await setupMessageListPage(tester, narrow: CombinedFeedNarrow(),
+        fetchResult: eg.nearGetMessagesResult(anchor: messages.last.id,
+          foundOldest: true, foundNewest: true, messages: messages));
+      check(findMessageListScrollController(tester)!.position)
+        .extentAfter.equals(0);
+
+      // There's no loading indicator.
+      check(findLoadingIndicator).findsNothing();
+      // The last message is spaced above the bottom of the viewport.
+      check(tester.getRect(find.text('message 9')))
+        .bottom..isGreaterThan(400)..isLessThan(570);
+    });
+
+    testWidgets('loading indicator displaces spacer etc.', (tester) async {
+      await setupMessageListPage(tester, narrow: CombinedFeedNarrow(),
+        skipPumpAndSettle: true,
+        // TODO(#1569) fix realism of this data: foundNewest false should mean
+        //   some messages found after anchor (and then we might need to scroll
+        //   to cause fetching newer messages).
+        fetchResult: eg.nearGetMessagesResult(anchor: 1000,
+          foundOldest: true, foundNewest: false,
+          messages: List.generate(10,
+            (i) => eg.streamMessage(id: 100 + i, content: '
message $i
'))));
+      await tester.pump();
+
+      // The message list will immediately start fetching newer messages.
+      connection.prepare(json: eg.newerGetMessagesResult(
+        anchor: 109, foundNewest: true, messages: List.generate(100,
+          (i) => eg.streamMessage(id: 110 + i))).toJson());
+      await tester.pump(Duration(milliseconds: 10));
+      await tester.pump();
+
+      // There's a loading indicator.
+      check(findLoadingIndicator).findsOne();
+      // It's at the bottom.
+      check(findMessageListScrollController(tester)!.position)
+        .extentAfter.equals(0);
+      final loadingIndicatorRect = tester.getRect(findLoadingIndicator);
+      check(loadingIndicatorRect).bottom.isGreaterThan(575);
+      // The last message is shortly above it; no spacer or anything else.
+      check(tester.getRect(find.text('message 9')))
+        .bottom.isGreaterThan(loadingIndicatorRect.top - 36); // TODO(#1569) where's this space going?
+      await tester.pumpAndSettle();
+    });
+
+    // TODO(#1569) test no typing status or mark-read button when not haveNewest
+    //   (even without loading indicator)
+  });
+
   group('TypingStatusWidget', () {
     final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
     final finder = find.descendant(