Repository: zulip/zulip-flutter (language: dart)

Issue:
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 



Discussion hints:
> 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.

Pull request: Implement mark-as-read-on-scroll, v1
(edited)

Fixes #81. 🎉

Still to do, but can be soon-after-launch:

- #1583
- #685 

Test command: 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

Fix patch:
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});
 


Test patch:
diff --git a/test/model/schemas/drift_schema_v8.json b/test/model/schemas/drift_schema_v8.json
new file mode 100644
index 0000000000..62f8ca43d0
--- /dev/null
+++ b/test/model/schemas/drift_schema_v8.json
@@ -0,0 +1,1 @@
+{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter<ThemeSetting>(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter<BrowserPreference>(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter<VisitFirstUnreadSetting>(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter<MarkReadOnScrollSetting>(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]}
\ No newline at end of file
diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart
index 87de9194d3..746206e453 100644
--- a/test/model/schemas/schema.dart
+++ b/test/model/schemas/schema.dart
@@ -10,6 +10,7 @@ import 'schema_v4.dart' as v4;
 import 'schema_v5.dart' as v5;
 import 'schema_v6.dart' as v6;
 import 'schema_v7.dart' as v7;
+import 'schema_v8.dart' as v8;
 
 class GeneratedHelper implements SchemaInstantiationHelper {
   @override
@@ -29,10 +30,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
         return v6.DatabaseAtV6(db);
       case 7:
         return v7.DatabaseAtV7(db);
+      case 8:
+        return v8.DatabaseAtV8(db);
       default:
         throw MissingSchemaException(version, versions);
     }
   }
 
-  static const versions = const [1, 2, 3, 4, 5, 6, 7];
+  static const versions = const [1, 2, 3, 4, 5, 6, 7, 8];
 }
diff --git a/test/model/schemas/schema_v8.dart b/test/model/schemas/schema_v8.dart
new file mode 100644
index 0000000000..fb17863b15
--- /dev/null
+++ b/test/model/schemas/schema_v8.dart
@@ -0,0 +1,967 @@
+// dart format width=80
+// GENERATED CODE, DO NOT EDIT BY HAND.
+// ignore_for_file: type=lint
+import 'package:drift/drift.dart';
+
+class GlobalSettings extends Table
+    with TableInfo<GlobalSettings, GlobalSettingsData> {
+  @override
+  final GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  GlobalSettings(this.attachedDatabase, [this._alias]);
+  late final GeneratedColumn<String> themeSetting = GeneratedColumn<String>(
+    'theme_setting',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  late final GeneratedColumn<String> browserPreference =
+      GeneratedColumn<String>(
+        'browser_preference',
+        aliasedName,
+        true,
+        type: DriftSqlType.string,
+        requiredDuringInsert: false,
+      );
+  late final GeneratedColumn<String> visitFirstUnread = GeneratedColumn<String>(
+    'visit_first_unread',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  late final GeneratedColumn<String> markReadOnScroll = GeneratedColumn<String>(
+    'mark_read_on_scroll',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  @override
+  List<GeneratedColumn> get $columns => [
+    themeSetting,
+    browserPreference,
+    visitFirstUnread,
+    markReadOnScroll,
+  ];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'global_settings';
+  @override
+  Set<GeneratedColumn> get $primaryKey => const {};
+  @override
+  GlobalSettingsData map(Map<String, dynamic> data, {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return GlobalSettingsData(
+      themeSetting: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}theme_setting'],
+      ),
+      browserPreference: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}browser_preference'],
+      ),
+      visitFirstUnread: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}visit_first_unread'],
+      ),
+      markReadOnScroll: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}mark_read_on_scroll'],
+      ),
+    );
+  }
+
+  @override
+  GlobalSettings createAlias(String alias) {
+    return GlobalSettings(attachedDatabase, alias);
+  }
+}
+
+class GlobalSettingsData extends DataClass
+    implements Insertable<GlobalSettingsData> {
+  final String? themeSetting;
+  final String? browserPreference;
+  final String? visitFirstUnread;
+  final String? markReadOnScroll;
+  const GlobalSettingsData({
+    this.themeSetting,
+    this.browserPreference,
+    this.visitFirstUnread,
+    this.markReadOnScroll,
+  });
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (!nullToAbsent || themeSetting != null) {
+      map['theme_setting'] = Variable<String>(themeSetting);
+    }
+    if (!nullToAbsent || browserPreference != null) {
+      map['browser_preference'] = Variable<String>(browserPreference);
+    }
+    if (!nullToAbsent || visitFirstUnread != null) {
+      map['visit_first_unread'] = Variable<String>(visitFirstUnread);
+    }
+    if (!nullToAbsent || markReadOnScroll != null) {
+      map['mark_read_on_scroll'] = Variable<String>(markReadOnScroll);
+    }
+    return map;
+  }
+
+  GlobalSettingsCompanion toCompanion(bool nullToAbsent) {
+    return GlobalSettingsCompanion(
+      themeSetting: themeSetting == null && nullToAbsent
+          ? const Value.absent()
+          : Value(themeSetting),
+      browserPreference: browserPreference == null && nullToAbsent
+          ? const Value.absent()
+          : Value(browserPreference),
+      visitFirstUnread: visitFirstUnread == null && nullToAbsent
+          ? const Value.absent()
+          : Value(visitFirstUnread),
+      markReadOnScroll: markReadOnScroll == null && nullToAbsent
+          ? const Value.absent()
+          : Value(markReadOnScroll),
+    );
+  }
+
+  factory GlobalSettingsData.fromJson(
+    Map<String, dynamic> json, {
+    ValueSerializer? serializer,
+  }) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return GlobalSettingsData(
+      themeSetting: serializer.fromJson<String?>(json['themeSetting']),
+      browserPreference: serializer.fromJson<String?>(
+        json['browserPreference'],
+      ),
+      visitFirstUnread: serializer.fromJson<String?>(json['visitFirstUnread']),
+      markReadOnScroll: serializer.fromJson<String?>(json['markReadOnScroll']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'themeSetting': serializer.toJson<String?>(themeSetting),
+      'browserPreference': serializer.toJson<String?>(browserPreference),
+      'visitFirstUnread': serializer.toJson<String?>(visitFirstUnread),
+      'markReadOnScroll': serializer.toJson<String?>(markReadOnScroll),
+    };
+  }
+
+  GlobalSettingsData copyWith({
+    Value<String?> themeSetting = const Value.absent(),
+    Value<String?> browserPreference = const Value.absent(),
+    Value<String?> visitFirstUnread = const Value.absent(),
+    Value<String?> markReadOnScroll = const Value.absent(),
+  }) => GlobalSettingsData(
+    themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting,
+    browserPreference: browserPreference.present
+        ? browserPreference.value
+        : this.browserPreference,
+    visitFirstUnread: visitFirstUnread.present
+        ? visitFirstUnread.value
+        : this.visitFirstUnread,
+    markReadOnScroll: markReadOnScroll.present
+        ? markReadOnScroll.value
+        : this.markReadOnScroll,
+  );
+  GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
+    return GlobalSettingsData(
+      themeSetting: data.themeSetting.present
+          ? data.themeSetting.value
+          : this.themeSetting,
+      browserPreference: data.browserPreference.present
+          ? data.browserPreference.value
+          : this.browserPreference,
+      visitFirstUnread: data.visitFirstUnread.present
+          ? data.visitFirstUnread.value
+          : this.visitFirstUnread,
+      markReadOnScroll: data.markReadOnScroll.present
+          ? data.markReadOnScroll.value
+          : this.markReadOnScroll,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('GlobalSettingsData(')
+          ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference, ')
+          ..write('visitFirstUnread: $visitFirstUnread, ')
+          ..write('markReadOnScroll: $markReadOnScroll')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  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.markReadOnScroll == this.markReadOnScroll);
+}
+
+class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
+  final Value<String?> themeSetting;
+  final Value<String?> browserPreference;
+  final Value<String?> visitFirstUnread;
+  final Value<String?> 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,
+    });
+  }
+
+  GlobalSettingsCompanion copyWith({
+    Value<String?>? themeSetting,
+    Value<String?>? browserPreference,
+    Value<String?>? visitFirstUnread,
+    Value<String?>? 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,
+    );
+  }
+
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (themeSetting.present) {
+      map['theme_setting'] = Variable<String>(themeSetting.value);
+    }
+    if (browserPreference.present) {
+      map['browser_preference'] = Variable<String>(browserPreference.value);
+    }
+    if (visitFirstUnread.present) {
+      map['visit_first_unread'] = Variable<String>(visitFirstUnread.value);
+    }
+    if (markReadOnScroll.present) {
+      map['mark_read_on_scroll'] = Variable<String>(markReadOnScroll.value);
+    }
+    if (rowid.present) {
+      map['rowid'] = Variable<int>(rowid.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('GlobalSettingsCompanion(')
+          ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference, ')
+          ..write('visitFirstUnread: $visitFirstUnread, ')
+          ..write('markReadOnScroll: $markReadOnScroll, ')
+          ..write('rowid: $rowid')
+          ..write(')'))
+        .toString();
+  }
+}
+
+class BoolGlobalSettings extends Table
+    with TableInfo<BoolGlobalSettings, BoolGlobalSettingsData> {
+  @override
+  final GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  BoolGlobalSettings(this.attachedDatabase, [this._alias]);
+  late final GeneratedColumn<String> name = GeneratedColumn<String>(
+    'name',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<bool> value = GeneratedColumn<bool>(
+    'value',
+    aliasedName,
+    false,
+    type: DriftSqlType.bool,
+    requiredDuringInsert: true,
+    defaultConstraints: GeneratedColumn.constraintIsAlways(
+      'CHECK ("value" IN (0, 1))',
+    ),
+  );
+  @override
+  List<GeneratedColumn> get $columns => [name, value];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'bool_global_settings';
+  @override
+  Set<GeneratedColumn> get $primaryKey => {name};
+  @override
+  BoolGlobalSettingsData map(Map<String, dynamic> data, {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return BoolGlobalSettingsData(
+      name: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}name'],
+      )!,
+      value: attachedDatabase.typeMapping.read(
+        DriftSqlType.bool,
+        data['${effectivePrefix}value'],
+      )!,
+    );
+  }
+
+  @override
+  BoolGlobalSettings createAlias(String alias) {
+    return BoolGlobalSettings(attachedDatabase, alias);
+  }
+}
+
+class BoolGlobalSettingsData extends DataClass
+    implements Insertable<BoolGlobalSettingsData> {
+  final String name;
+  final bool value;
+  const BoolGlobalSettingsData({required this.name, required this.value});
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    map['name'] = Variable<String>(name);
+    map['value'] = Variable<bool>(value);
+    return map;
+  }
+
+  BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) {
+    return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value));
+  }
+
+  factory BoolGlobalSettingsData.fromJson(
+    Map<String, dynamic> json, {
+    ValueSerializer? serializer,
+  }) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return BoolGlobalSettingsData(
+      name: serializer.fromJson<String>(json['name']),
+      value: serializer.fromJson<bool>(json['value']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'name': serializer.toJson<String>(name),
+      'value': serializer.toJson<bool>(value),
+    };
+  }
+
+  BoolGlobalSettingsData copyWith({String? name, bool? value}) =>
+      BoolGlobalSettingsData(
+        name: name ?? this.name,
+        value: value ?? this.value,
+      );
+  BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) {
+    return BoolGlobalSettingsData(
+      name: data.name.present ? data.name.value : this.name,
+      value: data.value.present ? data.value.value : this.value,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('BoolGlobalSettingsData(')
+          ..write('name: $name, ')
+          ..write('value: $value')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode => Object.hash(name, value);
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is BoolGlobalSettingsData &&
+          other.name == this.name &&
+          other.value == this.value);
+}
+
+class BoolGlobalSettingsCompanion
+    extends UpdateCompanion<BoolGlobalSettingsData> {
+  final Value<String> name;
+  final Value<bool> value;
+  final Value<int> rowid;
+  const BoolGlobalSettingsCompanion({
+    this.name = const Value.absent(),
+    this.value = const Value.absent(),
+    this.rowid = const Value.absent(),
+  });
+  BoolGlobalSettingsCompanion.insert({
+    required String name,
+    required bool value,
+    this.rowid = const Value.absent(),
+  }) : name = Value(name),
+       value = Value(value);
+  static Insertable<BoolGlobalSettingsData> custom({
+    Expression<String>? name,
+    Expression<bool>? value,
+    Expression<int>? rowid,
+  }) {
+    return RawValuesInsertable({
+      if (name != null) 'name': name,
+      if (value != null) 'value': value,
+      if (rowid != null) 'rowid': rowid,
+    });
+  }
+
+  BoolGlobalSettingsCompanion copyWith({
+    Value<String>? name,
+    Value<bool>? value,
+    Value<int>? rowid,
+  }) {
+    return BoolGlobalSettingsCompanion(
+      name: name ?? this.name,
+      value: value ?? this.value,
+      rowid: rowid ?? this.rowid,
+    );
+  }
+
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (name.present) {
+      map['name'] = Variable<String>(name.value);
+    }
+    if (value.present) {
+      map['value'] = Variable<bool>(value.value);
+    }
+    if (rowid.present) {
+      map['rowid'] = Variable<int>(rowid.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('BoolGlobalSettingsCompanion(')
+          ..write('name: $name, ')
+          ..write('value: $value, ')
+          ..write('rowid: $rowid')
+          ..write(')'))
+        .toString();
+  }
+}
+
+class Accounts extends Table with TableInfo<Accounts, AccountsData> {
+  @override
+  final GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  Accounts(this.attachedDatabase, [this._alias]);
+  late final GeneratedColumn<int> id = GeneratedColumn<int>(
+    'id',
+    aliasedName,
+    false,
+    hasAutoIncrement: true,
+    type: DriftSqlType.int,
+    requiredDuringInsert: false,
+    defaultConstraints: GeneratedColumn.constraintIsAlways(
+      'PRIMARY KEY AUTOINCREMENT',
+    ),
+  );
+  late final GeneratedColumn<String> realmUrl = GeneratedColumn<String>(
+    'realm_url',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<int> userId = GeneratedColumn<int>(
+    'user_id',
+    aliasedName,
+    false,
+    type: DriftSqlType.int,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> email = GeneratedColumn<String>(
+    'email',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> apiKey = GeneratedColumn<String>(
+    'api_key',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> zulipVersion = GeneratedColumn<String>(
+    'zulip_version',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> zulipMergeBase = GeneratedColumn<String>(
+    'zulip_merge_base',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  late final GeneratedColumn<int> zulipFeatureLevel = GeneratedColumn<int>(
+    'zulip_feature_level',
+    aliasedName,
+    false,
+    type: DriftSqlType.int,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> ackedPushToken = GeneratedColumn<String>(
+    'acked_push_token',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  @override
+  List<GeneratedColumn> get $columns => [
+    id,
+    realmUrl,
+    userId,
+    email,
+    apiKey,
+    zulipVersion,
+    zulipMergeBase,
+    zulipFeatureLevel,
+    ackedPushToken,
+  ];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'accounts';
+  @override
+  Set<GeneratedColumn> get $primaryKey => {id};
+  @override
+  List<Set<GeneratedColumn>> get uniqueKeys => [
+    {realmUrl, userId},
+    {realmUrl, email},
+  ];
+  @override
+  AccountsData map(Map<String, dynamic> data, {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return AccountsData(
+      id: attachedDatabase.typeMapping.read(
+        DriftSqlType.int,
+        data['${effectivePrefix}id'],
+      )!,
+      realmUrl: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}realm_url'],
+      )!,
+      userId: attachedDatabase.typeMapping.read(
+        DriftSqlType.int,
+        data['${effectivePrefix}user_id'],
+      )!,
+      email: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}email'],
+      )!,
+      apiKey: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}api_key'],
+      )!,
+      zulipVersion: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}zulip_version'],
+      )!,
+      zulipMergeBase: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}zulip_merge_base'],
+      ),
+      zulipFeatureLevel: attachedDatabase.typeMapping.read(
+        DriftSqlType.int,
+        data['${effectivePrefix}zulip_feature_level'],
+      )!,
+      ackedPushToken: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}acked_push_token'],
+      ),
+    );
+  }
+
+  @override
+  Accounts createAlias(String alias) {
+    return Accounts(attachedDatabase, alias);
+  }
+}
+
+class AccountsData extends DataClass implements Insertable<AccountsData> {
+  final int id;
+  final String realmUrl;
+  final int userId;
+  final String email;
+  final String apiKey;
+  final String zulipVersion;
+  final String? zulipMergeBase;
+  final int zulipFeatureLevel;
+  final String? ackedPushToken;
+  const AccountsData({
+    required this.id,
+    required this.realmUrl,
+    required this.userId,
+    required this.email,
+    required this.apiKey,
+    required this.zulipVersion,
+    this.zulipMergeBase,
+    required this.zulipFeatureLevel,
+    this.ackedPushToken,
+  });
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    map['id'] = Variable<int>(id);
+    map['realm_url'] = Variable<String>(realmUrl);
+    map['user_id'] = Variable<int>(userId);
+    map['email'] = Variable<String>(email);
+    map['api_key'] = Variable<String>(apiKey);
+    map['zulip_version'] = Variable<String>(zulipVersion);
+    if (!nullToAbsent || zulipMergeBase != null) {
+      map['zulip_merge_base'] = Variable<String>(zulipMergeBase);
+    }
+    map['zulip_feature_level'] = Variable<int>(zulipFeatureLevel);
+    if (!nullToAbsent || ackedPushToken != null) {
+      map['acked_push_token'] = Variable<String>(ackedPushToken);
+    }
+    return map;
+  }
+
+  AccountsCompanion toCompanion(bool nullToAbsent) {
+    return AccountsCompanion(
+      id: Value(id),
+      realmUrl: Value(realmUrl),
+      userId: Value(userId),
+      email: Value(email),
+      apiKey: Value(apiKey),
+      zulipVersion: Value(zulipVersion),
+      zulipMergeBase: zulipMergeBase == null && nullToAbsent
+          ? const Value.absent()
+          : Value(zulipMergeBase),
+      zulipFeatureLevel: Value(zulipFeatureLevel),
+      ackedPushToken: ackedPushToken == null && nullToAbsent
+          ? const Value.absent()
+          : Value(ackedPushToken),
+    );
+  }
+
+  factory AccountsData.fromJson(
+    Map<String, dynamic> json, {
+    ValueSerializer? serializer,
+  }) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return AccountsData(
+      id: serializer.fromJson<int>(json['id']),
+      realmUrl: serializer.fromJson<String>(json['realmUrl']),
+      userId: serializer.fromJson<int>(json['userId']),
+      email: serializer.fromJson<String>(json['email']),
+      apiKey: serializer.fromJson<String>(json['apiKey']),
+      zulipVersion: serializer.fromJson<String>(json['zulipVersion']),
+      zulipMergeBase: serializer.fromJson<String?>(json['zulipMergeBase']),
+      zulipFeatureLevel: serializer.fromJson<int>(json['zulipFeatureLevel']),
+      ackedPushToken: serializer.fromJson<String?>(json['ackedPushToken']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'id': serializer.toJson<int>(id),
+      'realmUrl': serializer.toJson<String>(realmUrl),
+      'userId': serializer.toJson<int>(userId),
+      'email': serializer.toJson<String>(email),
+      'apiKey': serializer.toJson<String>(apiKey),
+      'zulipVersion': serializer.toJson<String>(zulipVersion),
+      'zulipMergeBase': serializer.toJson<String?>(zulipMergeBase),
+      'zulipFeatureLevel': serializer.toJson<int>(zulipFeatureLevel),
+      'ackedPushToken': serializer.toJson<String?>(ackedPushToken),
+    };
+  }
+
+  AccountsData copyWith({
+    int? id,
+    String? realmUrl,
+    int? userId,
+    String? email,
+    String? apiKey,
+    String? zulipVersion,
+    Value<String?> zulipMergeBase = const Value.absent(),
+    int? zulipFeatureLevel,
+    Value<String?> ackedPushToken = const Value.absent(),
+  }) => AccountsData(
+    id: id ?? this.id,
+    realmUrl: realmUrl ?? this.realmUrl,
+    userId: userId ?? this.userId,
+    email: email ?? this.email,
+    apiKey: apiKey ?? this.apiKey,
+    zulipVersion: zulipVersion ?? this.zulipVersion,
+    zulipMergeBase: zulipMergeBase.present
+        ? zulipMergeBase.value
+        : this.zulipMergeBase,
+    zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel,
+    ackedPushToken: ackedPushToken.present
+        ? ackedPushToken.value
+        : this.ackedPushToken,
+  );
+  AccountsData copyWithCompanion(AccountsCompanion data) {
+    return AccountsData(
+      id: data.id.present ? data.id.value : this.id,
+      realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl,
+      userId: data.userId.present ? data.userId.value : this.userId,
+      email: data.email.present ? data.email.value : this.email,
+      apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey,
+      zulipVersion: data.zulipVersion.present
+          ? data.zulipVersion.value
+          : this.zulipVersion,
+      zulipMergeBase: data.zulipMergeBase.present
+          ? data.zulipMergeBase.value
+          : this.zulipMergeBase,
+      zulipFeatureLevel: data.zulipFeatureLevel.present
+          ? data.zulipFeatureLevel.value
+          : this.zulipFeatureLevel,
+      ackedPushToken: data.ackedPushToken.present
+          ? data.ackedPushToken.value
+          : this.ackedPushToken,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('AccountsData(')
+          ..write('id: $id, ')
+          ..write('realmUrl: $realmUrl, ')
+          ..write('userId: $userId, ')
+          ..write('email: $email, ')
+          ..write('apiKey: $apiKey, ')
+          ..write('zulipVersion: $zulipVersion, ')
+          ..write('zulipMergeBase: $zulipMergeBase, ')
+          ..write('zulipFeatureLevel: $zulipFeatureLevel, ')
+          ..write('ackedPushToken: $ackedPushToken')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode => Object.hash(
+    id,
+    realmUrl,
+    userId,
+    email,
+    apiKey,
+    zulipVersion,
+    zulipMergeBase,
+    zulipFeatureLevel,
+    ackedPushToken,
+  );
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is AccountsData &&
+          other.id == this.id &&
+          other.realmUrl == this.realmUrl &&
+          other.userId == this.userId &&
+          other.email == this.email &&
+          other.apiKey == this.apiKey &&
+          other.zulipVersion == this.zulipVersion &&
+          other.zulipMergeBase == this.zulipMergeBase &&
+          other.zulipFeatureLevel == this.zulipFeatureLevel &&
+          other.ackedPushToken == this.ackedPushToken);
+}
+
+class AccountsCompanion extends UpdateCompanion<AccountsData> {
+  final Value<int> id;
+  final Value<String> realmUrl;
+  final Value<int> userId;
+  final Value<String> email;
+  final Value<String> apiKey;
+  final Value<String> zulipVersion;
+  final Value<String?> zulipMergeBase;
+  final Value<int> zulipFeatureLevel;
+  final Value<String?> ackedPushToken;
+  const AccountsCompanion({
+    this.id = const Value.absent(),
+    this.realmUrl = const Value.absent(),
+    this.userId = const Value.absent(),
+    this.email = const Value.absent(),
+    this.apiKey = const Value.absent(),
+    this.zulipVersion = const Value.absent(),
+    this.zulipMergeBase = const Value.absent(),
+    this.zulipFeatureLevel = const Value.absent(),
+    this.ackedPushToken = const Value.absent(),
+  });
+  AccountsCompanion.insert({
+    this.id = const Value.absent(),
+    required String realmUrl,
+    required int userId,
+    required String email,
+    required String apiKey,
+    required String zulipVersion,
+    this.zulipMergeBase = const Value.absent(),
+    required int zulipFeatureLevel,
+    this.ackedPushToken = const Value.absent(),
+  }) : realmUrl = Value(realmUrl),
+       userId = Value(userId),
+       email = Value(email),
+       apiKey = Value(apiKey),
+       zulipVersion = Value(zulipVersion),
+       zulipFeatureLevel = Value(zulipFeatureLevel);
+  static Insertable<AccountsData> custom({
+    Expression<int>? id,
+    Expression<String>? realmUrl,
+    Expression<int>? userId,
+    Expression<String>? email,
+    Expression<String>? apiKey,
+    Expression<String>? zulipVersion,
+    Expression<String>? zulipMergeBase,
+    Expression<int>? zulipFeatureLevel,
+    Expression<String>? ackedPushToken,
+  }) {
+    return RawValuesInsertable({
+      if (id != null) 'id': id,
+      if (realmUrl != null) 'realm_url': realmUrl,
+      if (userId != null) 'user_id': userId,
+      if (email != null) 'email': email,
+      if (apiKey != null) 'api_key': apiKey,
+      if (zulipVersion != null) 'zulip_version': zulipVersion,
+      if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase,
+      if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel,
+      if (ackedPushToken != null) 'acked_push_token': ackedPushToken,
+    });
+  }
+
+  AccountsCompanion copyWith({
+    Value<int>? id,
+    Value<String>? realmUrl,
+    Value<int>? userId,
+    Value<String>? email,
+    Value<String>? apiKey,
+    Value<String>? zulipVersion,
+    Value<String?>? zulipMergeBase,
+    Value<int>? zulipFeatureLevel,
+    Value<String?>? ackedPushToken,
+  }) {
+    return AccountsCompanion(
+      id: id ?? this.id,
+      realmUrl: realmUrl ?? this.realmUrl,
+      userId: userId ?? this.userId,
+      email: email ?? this.email,
+      apiKey: apiKey ?? this.apiKey,
+      zulipVersion: zulipVersion ?? this.zulipVersion,
+      zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase,
+      zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel,
+      ackedPushToken: ackedPushToken ?? this.ackedPushToken,
+    );
+  }
+
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (id.present) {
+      map['id'] = Variable<int>(id.value);
+    }
+    if (realmUrl.present) {
+      map['realm_url'] = Variable<String>(realmUrl.value);
+    }
+    if (userId.present) {
+      map['user_id'] = Variable<int>(userId.value);
+    }
+    if (email.present) {
+      map['email'] = Variable<String>(email.value);
+    }
+    if (apiKey.present) {
+      map['api_key'] = Variable<String>(apiKey.value);
+    }
+    if (zulipVersion.present) {
+      map['zulip_version'] = Variable<String>(zulipVersion.value);
+    }
+    if (zulipMergeBase.present) {
+      map['zulip_merge_base'] = Variable<String>(zulipMergeBase.value);
+    }
+    if (zulipFeatureLevel.present) {
+      map['zulip_feature_level'] = Variable<int>(zulipFeatureLevel.value);
+    }
+    if (ackedPushToken.present) {
+      map['acked_push_token'] = Variable<String>(ackedPushToken.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('AccountsCompanion(')
+          ..write('id: $id, ')
+          ..write('realmUrl: $realmUrl, ')
+          ..write('userId: $userId, ')
+          ..write('email: $email, ')
+          ..write('apiKey: $apiKey, ')
+          ..write('zulipVersion: $zulipVersion, ')
+          ..write('zulipMergeBase: $zulipMergeBase, ')
+          ..write('zulipFeatureLevel: $zulipFeatureLevel, ')
+          ..write('ackedPushToken: $ackedPushToken')
+          ..write(')'))
+        .toString();
+  }
+}
+
+class DatabaseAtV8 extends GeneratedDatabase {
+  DatabaseAtV8(QueryExecutor e) : super(e);
+  late final GlobalSettings globalSettings = GlobalSettings(this);
+  late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this);
+  late final Accounts accounts = Accounts(this);
+  @override
+  Iterable<TableInfo<Table, Object?>> get allTables =>
+      allSchemaEntities.whereType<TableInfo<Table, Object?>>();
+  @override
+  List<DatabaseSchemaEntity> get allSchemaEntities => [
+    globalSettings,
+    boolGlobalSettings,
+    accounts,
+  ];
+  @override
+  int get schemaVersion => 8;
+}
diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart
index 89956323e2..b4842ecd04 100644
--- a/test/model/settings_test.dart
+++ b/test/model/settings_test.dart
@@ -80,6 +80,9 @@ void main() {
   // TODO(#1571) test visitFirstUnread applies default
   // TODO(#1571) test shouldVisitFirstUnread
 
+  // TODO(#1583) test markReadOnScroll applies default
+  // TODO(#1583) test markReadOnScrollForNarrow
+
   group('getBool/setBool', () {
     test('get from default', () {
       final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings;
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 16cc36b096..ebb6cb9b71 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -114,6 +114,7 @@ Future<void> setupToMessageActionSheet(WidgetTester tester, {
 void main() {
   TestZulipBinding.ensureInitialized();
   TestWidgetsFlutterBinding.ensureInitialized();
+  MessageListPage.debugEnableMarkReadOnScroll = false;
 
   void prepareRawContentResponseSuccess({
     required Message message,
diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart
index b4ff007a8d..573921b663 100644
--- a/test/widgets/autocomplete_test.dart
+++ b/test/widgets/autocomplete_test.dart
@@ -145,6 +145,7 @@ typedef ExpectedEmoji = (String label, EmojiDisplay display);
 
 void main() {
   TestZulipBinding.ensureInitialized();
+  MessageListPage.debugEnableMarkReadOnScroll = false;
 
   group('@-mentions', () {
 
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index 70f0913316..76305610c6 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -43,6 +43,7 @@ import 'test_app.dart';
 
 void main() {
   TestZulipBinding.ensureInitialized();
+  MessageListPage.debugEnableMarkReadOnScroll = false;
 
   late PerAccountStore store;
   late FakeApiConnection connection;
diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart
index de3ad7227c..9ff4849b1b 100644
--- a/test/widgets/emoji_reaction_test.dart
+++ b/test/widgets/emoji_reaction_test.dart
@@ -36,6 +36,7 @@ import 'text_test.dart';
 
 void main() {
   TestZulipBinding.ensureInitialized();
+  MessageListPage.debugEnableMarkReadOnScroll = false;
 
   late PerAccountStore store;
   late FakeApiConnection connection;
diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart
index fda7122123..3165222c45 100644
--- a/test/widgets/lightbox_test.dart
+++ b/test/widgets/lightbox_test.dart
@@ -203,6 +203,7 @@ class FakeVideoPlayerPlatform extends Fake
 
 void main() {
   TestZulipBinding.ensureInitialized();
+  MessageListPage.debugEnableMarkReadOnScroll = false;
 
   group('LightboxHero', () {
     late PerAccountStore store;
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index f048bf437d..fd8dd6f10b 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -52,6 +52,7 @@ import 'test_app.dart';
 
 void main() {
   TestZulipBinding.ensureInitialized();
+  MessageListPage.debugEnableMarkReadOnScroll = false;
 
   late PerAccountStore store;
   late FakeApiConnection connection;