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

Issue:
Open message list at first unread, rather than latest message
When navigating to a message list, our default behavior (if there isn't a specific message we're asked to go to) should be to show the first unread message, if any, and the messages around it.

This means
* fetching around the `first_unread` anchor (https://zulip.com/api/get-messages), rather than `latest`;
* controlling the scroll position so that we show the first unread, if any, and the latest messages otherwise.

This should probably come after:
* #79

A followup is:
* #81

Open a message list in the middle of history, when requested
When the user takes an action that should navigate to a particular "anchor" message (like through #73 internal links, #76 deep links, or a notification), we should take them straight to that message, rather than to the latest message or first unread message in the narrow.

This is a feature the zulip-mobile RN app lacks: https://github.com/zulip/zulip-mobile/issues/3604 . (That's for internal links, but the app doesn't have it for notifications either — see `narrowToNotification` which passes no anchor to `doNarrow` — and doesn't have deep links.)

There are two parts to this:
* Fetch the messages around the desired anchor, rather than around the latest message or the first unread.
* Control the scroll position so that we show the desired anchor message.

This has a lot in common with:
* #80

This issue has fewer prerequisites technically than #80 — no need for #79 first. On the other hand it has more prerequisites before there's a UX context where it makes sense: it'd need either #73, #76, or notifications, all of which are more complex than #79.


Discussion hints:

Started working on this.

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

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

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


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

Pull request: anchors n/n: Open at first unread
Fixes #80.

This is stacked atop #1566.

This is marked as a draft only because of a lack of tests. The functionality is fully implemented, and otherwise ready for review.

---

This implementation has one key difference from the legacy app and from the original plan for #80:

When fetching at first unread, it turns out the resulting /api/get-messages request can be quite slow, if the user's first unread is far back in history (e.g. in combined feed or a busy channel). This seems to be on the server side — probably it's just a naturally slower query for the database to handle.

In retrospect this is probably a major contributor (though far from the only one) to why the legacy RN app has always felt slow to me.

In this PR, fetching at first unread therefore (a) is controlled by a setting, and (b) by default is done only for conversation views, not interleaved views.

If I turn the setting to "always go to first unread", opening my combined feed on chat.zulip.org takes multiple seconds. But then if I tap the "go to bottom" button, it loads the newest messages like usual, and that is zippy like zulip-flutter has always been. (Also if I scroll around in history, even way back in the vicinity of the first unread, it remains as zippy as always.)

Without that setting (or if the default were "always go to first unread"), this would be a substantial regression relative to zulip-flutter main for users who have old unreads, and would give up a large part of this app's ability to impress users with its zippy handling of messages.

---

Previous notes about the portion that now belongs to #1566:

The main remaining work this branch needs is tests. There are also some directly user-visible aspects still to do:
* When opening a /near/ link, this PR ensures the feed opens with the target message in view. But it'd be good to visibly highlight the specific message, e.g. by having its background blink once or twice.

  That will probably be a post-launch follow-up — the lack of it isn't a regression relative to either zulip-flutter main, or the legacy app.
  (→ Filed as #1564.)

* When opening a /near/ link, this doesn't implement following where the message has been moved to.

  That's #683. Currently M6 but arguably should be M5b; either way, won't block this, and most likely won't happen before launch (which is coming soon).



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

Fix patch:
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index fb62815eef..1f070feb19 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -951,6 +951,26 @@
   "@pollWidgetOptionsMissing": {
     "description": "Text to display for a poll when it has no options"
   },
+  "initialAnchorSettingTitle": "Open message feeds at",
+  "@initialAnchorSettingTitle": {
+    "description": "Title of setting controlling initial anchor of message list."
+  },
+  "initialAnchorSettingDescription": "You can choose whether message feeds open at your first unread message or at the newest messages.",
+  "@initialAnchorSettingDescription": {
+    "description": "Description of setting controlling initial anchor of message list."
+  },
+  "initialAnchorSettingFirstUnreadAlways": "First unread message",
+  "@initialAnchorSettingFirstUnreadAlways":  {
+    "description": "Label for a value of setting controlling initial anchor of message list."
+  },
+  "initialAnchorSettingFirstUnreadConversations": "First unread message in single conversations, newest message elsewhere",
+  "@initialAnchorSettingFirstUnreadConversations":  {
+    "description": "Label for a value of setting controlling initial anchor of message list."
+  },
+  "initialAnchorSettingNewestAlways": "Newest message",
+  "@initialAnchorSettingNewestAlways":  {
+    "description": "Label for a value of setting controlling initial anchor of message list."
+  },
   "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 c990e54155..28f4eee3ba 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -1415,6 +1415,36 @@ abstract class ZulipLocalizations {
   /// **'This poll has no options yet.'**
   String get pollWidgetOptionsMissing;
 
+  /// Title of setting controlling initial anchor of message list.
+  ///
+  /// In en, this message translates to:
+  /// **'Open message feeds at'**
+  String get initialAnchorSettingTitle;
+
+  /// Description of setting controlling initial anchor of message list.
+  ///
+  /// In en, this message translates to:
+  /// **'You can choose whether message feeds open at your first unread message or at the newest messages.'**
+  String get initialAnchorSettingDescription;
+
+  /// Label for a value of setting controlling initial anchor of message list.
+  ///
+  /// In en, this message translates to:
+  /// **'First unread message'**
+  String get initialAnchorSettingFirstUnreadAlways;
+
+  /// Label for a value of setting controlling initial anchor of message list.
+  ///
+  /// In en, this message translates to:
+  /// **'First unread message in single conversations, newest message elsewhere'**
+  String get initialAnchorSettingFirstUnreadConversations;
+
+  /// Label for a value of setting controlling initial anchor of message list.
+  ///
+  /// In en, this message translates to:
+  /// **'Newest message'**
+  String get initialAnchorSettingNewestAlways;
+
   /// 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 50f55ee551..104cc16822 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -773,6 +773,23 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @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 43a5becc51..0abb3c2e55 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -773,6 +773,23 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @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 de74e5d0e7..d5201bad84 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -773,6 +773,23 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @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 3d96c28e2f..69a2e97816 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -773,6 +773,23 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @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 029c385574..66198f6a40 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -773,6 +773,23 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @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 0752efa496..b0189c01fc 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -784,6 +784,23 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'Ta sonda nie ma opcji do wyboru.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @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 22d2d7a337..063b3c01e4 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -787,6 +787,23 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'В опросе пока нет вариантов ответа.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @override
   String get experimentalFeatureSettingsPageTitle =>
       'Экспериментальные функции';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 132a5355e9..ec7a8f36e4 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -775,6 +775,23 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';
 
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index d76cbec6d9..af57ca0f86 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -787,6 +787,23 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   String get pollWidgetOptionsMissing =>
       'У цьому опитуванні ще немає варіантів.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Експериментальні функції';
 
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 4e74b4c95c..3b425dcea1 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -773,6 +773,23 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   @override
   String get pollWidgetOptionsMissing => 'This poll has no options yet.';
 
+  @override
+  String get initialAnchorSettingTitle => 'Open message feeds at';
+
+  @override
+  String get initialAnchorSettingDescription =>
+      'You can choose whether message feeds open at your first unread message or at the newest messages.';
+
+  @override
+  String get initialAnchorSettingFirstUnreadAlways => 'First unread message';
+
+  @override
+  String get initialAnchorSettingFirstUnreadConversations =>
+      'First unread message in single conversations, newest message elsewhere';
+
+  @override
+  String get initialAnchorSettingNewestAlways => 'Newest message';
+
   @override
   String get experimentalFeatureSettingsPageTitle => 'Experimental features';
 
diff --git a/lib/model/database.dart b/lib/model/database.dart
index 57910e7a50..f7d85b4b95 100644
--- a/lib/model/database.dart
+++ b/lib/model/database.dart
@@ -24,6 +24,9 @@ class GlobalSettings extends Table {
   Column<String> get browserPreference => textEnum<BrowserPreference>()
     .nullable()();
 
+  Column<String> get visitFirstUnread => textEnum<VisitFirstUnreadSetting>()
+    .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
@@ -119,7 +122,7 @@ class AppDatabase extends _$AppDatabase {
   //    information on using the build_runner.
   //  * Write a migration in `_migrationSteps` below.
   //  * Write tests.
-  static const int latestSchemaVersion = 6; // See note.
+  static const int latestSchemaVersion = 7; // See note.
 
   @override
   int get schemaVersion => latestSchemaVersion;
@@ -174,6 +177,10 @@ class AppDatabase extends _$AppDatabase {
     from5To6: (m, schema) async {
       await m.createTable(schema.boolGlobalSettings);
     },
+    from6To7: (m, schema) async {
+      await m.addColumn(schema.globalSettings,
+        schema.globalSettings.visitFirstUnread);
+    },
   );
 
   Future<void> _createLatestSchema(Migrator m) async {
diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart
index 99752bdd62..19c9f35c5a 100644
--- a/lib/model/database.g.dart
+++ b/lib/model/database.g.dart
@@ -32,7 +32,22 @@ class $GlobalSettingsTable extends GlobalSettings
     $GlobalSettingsTable.$converterbrowserPreferencen,
   );
   @override
-  List<GeneratedColumn> get $columns => [themeSetting, browserPreference];
+  late final GeneratedColumnWithTypeConverter<VisitFirstUnreadSetting?, String>
+  visitFirstUnread = GeneratedColumn<String>(
+    'visit_first_unread',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  ).withConverter<VisitFirstUnreadSetting?>(
+    $GlobalSettingsTable.$convertervisitFirstUnreadn,
+  );
+  @override
+  List<GeneratedColumn> get $columns => [
+    themeSetting,
+    browserPreference,
+    visitFirstUnread,
+  ];
   @override
   String get aliasedName => _alias ?? actualTableName;
   @override
@@ -57,6 +72,13 @@ class $GlobalSettingsTable extends GlobalSettings
               data['${effectivePrefix}browser_preference'],
             ),
           ),
+      visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn
+          .fromSql(
+            attachedDatabase.typeMapping.read(
+              DriftSqlType.string,
+              data['${effectivePrefix}visit_first_unread'],
+            ),
+          ),
     );
   }
 
@@ -81,13 +103,26 @@ class $GlobalSettingsTable extends GlobalSettings
   $converterbrowserPreferencen = JsonTypeConverter2.asNullable(
     $converterbrowserPreference,
   );
+  static JsonTypeConverter2<VisitFirstUnreadSetting, String, String>
+  $convertervisitFirstUnread = const EnumNameConverter<VisitFirstUnreadSetting>(
+    VisitFirstUnreadSetting.values,
+  );
+  static JsonTypeConverter2<VisitFirstUnreadSetting?, String?, String?>
+  $convertervisitFirstUnreadn = JsonTypeConverter2.asNullable(
+    $convertervisitFirstUnread,
+  );
 }
 
 class GlobalSettingsData extends DataClass
     implements Insertable<GlobalSettingsData> {
   final ThemeSetting? themeSetting;
   final BrowserPreference? browserPreference;
-  const GlobalSettingsData({this.themeSetting, this.browserPreference});
+  final VisitFirstUnreadSetting? visitFirstUnread;
+  const GlobalSettingsData({
+    this.themeSetting,
+    this.browserPreference,
+    this.visitFirstUnread,
+  });
   @override
   Map<String, Expression> toColumns(bool nullToAbsent) {
     final map = <String, Expression>{};
@@ -103,6 +138,13 @@ class GlobalSettingsData extends DataClass
         ),
       );
     }
+    if (!nullToAbsent || visitFirstUnread != null) {
+      map['visit_first_unread'] = Variable<String>(
+        $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql(
+          visitFirstUnread,
+        ),
+      );
+    }
     return map;
   }
 
@@ -116,6 +158,10 @@ class GlobalSettingsData extends DataClass
           browserPreference == null && nullToAbsent
               ? const Value.absent()
               : Value(browserPreference),
+      visitFirstUnread:
+          visitFirstUnread == null && nullToAbsent
+              ? const Value.absent()
+              : Value(visitFirstUnread),
     );
   }
 
@@ -130,6 +176,8 @@ class GlobalSettingsData extends DataClass
       ),
       browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen
           .fromJson(serializer.fromJson<String?>(json['browserPreference'])),
+      visitFirstUnread: $GlobalSettingsTable.$convertervisitFirstUnreadn
+          .fromJson(serializer.fromJson<String?>(json['visitFirstUnread'])),
     );
   }
   @override
@@ -144,18 +192,28 @@ class GlobalSettingsData extends DataClass
           browserPreference,
         ),
       ),
+      'visitFirstUnread': serializer.toJson<String?>(
+        $GlobalSettingsTable.$convertervisitFirstUnreadn.toJson(
+          visitFirstUnread,
+        ),
+      ),
     };
   }
 
   GlobalSettingsData copyWith({
     Value<ThemeSetting?> themeSetting = const Value.absent(),
     Value<BrowserPreference?> browserPreference = const Value.absent(),
+    Value<VisitFirstUnreadSetting?> visitFirstUnread = 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,
   );
   GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
     return GlobalSettingsData(
@@ -167,6 +225,10 @@ class GlobalSettingsData extends DataClass
           data.browserPreference.present
               ? data.browserPreference.value
               : this.browserPreference,
+      visitFirstUnread:
+          data.visitFirstUnread.present
+              ? data.visitFirstUnread.value
+              : this.visitFirstUnread,
     );
   }
 
@@ -174,43 +236,51 @@ class GlobalSettingsData extends DataClass
   String toString() {
     return (StringBuffer('GlobalSettingsData(')
           ..write('themeSetting: $themeSetting, ')
-          ..write('browserPreference: $browserPreference')
+          ..write('browserPreference: $browserPreference, ')
+          ..write('visitFirstUnread: $visitFirstUnread')
           ..write(')'))
         .toString();
   }
 
   @override
-  int get hashCode => Object.hash(themeSetting, browserPreference);
+  int get hashCode =>
+      Object.hash(themeSetting, browserPreference, visitFirstUnread);
   @override
   bool operator ==(Object other) =>
       identical(this, other) ||
       (other is GlobalSettingsData &&
           other.themeSetting == this.themeSetting &&
-          other.browserPreference == this.browserPreference);
+          other.browserPreference == this.browserPreference &&
+          other.visitFirstUnread == this.visitFirstUnread);
 }
 
 class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
   final Value<ThemeSetting?> themeSetting;
   final Value<BrowserPreference?> browserPreference;
+  final Value<VisitFirstUnreadSetting?> visitFirstUnread;
   final Value<int> rowid;
   const GlobalSettingsCompanion({
     this.themeSetting = const Value.absent(),
     this.browserPreference = const Value.absent(),
+    this.visitFirstUnread = 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.rowid = const Value.absent(),
   });
   static Insertable<GlobalSettingsData> custom({
     Expression<String>? themeSetting,
     Expression<String>? browserPreference,
+    Expression<String>? visitFirstUnread,
     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 (rowid != null) 'rowid': rowid,
     });
   }
@@ -218,11 +288,13 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
   GlobalSettingsCompanion copyWith({
     Value<ThemeSetting?>? themeSetting,
     Value<BrowserPreference?>? browserPreference,
+    Value<VisitFirstUnreadSetting?>? visitFirstUnread,
     Value<int>? rowid,
   }) {
     return GlobalSettingsCompanion(
       themeSetting: themeSetting ?? this.themeSetting,
       browserPreference: browserPreference ?? this.browserPreference,
+      visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread,
       rowid: rowid ?? this.rowid,
     );
   }
@@ -242,6 +314,13 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
         ),
       );
     }
+    if (visitFirstUnread.present) {
+      map['visit_first_unread'] = Variable<String>(
+        $GlobalSettingsTable.$convertervisitFirstUnreadn.toSql(
+          visitFirstUnread.value,
+        ),
+      );
+    }
     if (rowid.present) {
       map['rowid'] = Variable<int>(rowid.value);
     }
@@ -253,6 +332,7 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
     return (StringBuffer('GlobalSettingsCompanion(')
           ..write('themeSetting: $themeSetting, ')
           ..write('browserPreference: $browserPreference, ')
+          ..write('visitFirstUnread: $visitFirstUnread, ')
           ..write('rowid: $rowid')
           ..write(')'))
         .toString();
@@ -1109,12 +1189,14 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder =
     GlobalSettingsCompanion Function({
       Value<ThemeSetting?> themeSetting,
       Value<BrowserPreference?> browserPreference,
+      Value<VisitFirstUnreadSetting?> visitFirstUnread,
       Value<int> rowid,
     });
 typedef $$GlobalSettingsTableUpdateCompanionBuilder =
     GlobalSettingsCompanion Function({
       Value<ThemeSetting?> themeSetting,
       Value<BrowserPreference?> browserPreference,
+      Value<VisitFirstUnreadSetting?> visitFirstUnread,
       Value<int> rowid,
     });
 
@@ -1138,6 +1220,16 @@ class $$GlobalSettingsTableFilterComposer
     column: $table.browserPreference,
     builder: (column) => ColumnWithTypeConverterFilters(column),
   );
+
+  ColumnWithTypeConverterFilters<
+    VisitFirstUnreadSetting?,
+    VisitFirstUnreadSetting,
+    String
+  >
+  get visitFirstUnread => $composableBuilder(
+    column: $table.visitFirstUnread,
+    builder: (column) => ColumnWithTypeConverterFilters(column),
+  );
 }
 
 class $$GlobalSettingsTableOrderingComposer
@@ -1158,6 +1250,11 @@ class $$GlobalSettingsTableOrderingComposer
     column: $table.browserPreference,
     builder: (column) => ColumnOrderings(column),
   );
+
+  ColumnOrderings<String> get visitFirstUnread => $composableBuilder(
+    column: $table.visitFirstUnread,
+    builder: (column) => ColumnOrderings(column),
+  );
 }
 
 class $$GlobalSettingsTableAnnotationComposer
@@ -1180,6 +1277,12 @@ class $$GlobalSettingsTableAnnotationComposer
     column: $table.browserPreference,
     builder: (column) => column,
   );
+
+  GeneratedColumnWithTypeConverter<VisitFirstUnreadSetting?, String>
+  get visitFirstUnread => $composableBuilder(
+    column: $table.visitFirstUnread,
+    builder: (column) => column,
+  );
 }
 
 class $$GlobalSettingsTableTableManager
@@ -1226,10 +1329,13 @@ class $$GlobalSettingsTableTableManager
                 Value<ThemeSetting?> themeSetting = const Value.absent(),
                 Value<BrowserPreference?> browserPreference =
                     const Value.absent(),
+                Value<VisitFirstUnreadSetting?> visitFirstUnread =
+                    const Value.absent(),
                 Value<int> rowid = const Value.absent(),
               }) => GlobalSettingsCompanion(
                 themeSetting: themeSetting,
                 browserPreference: browserPreference,
+                visitFirstUnread: visitFirstUnread,
                 rowid: rowid,
               ),
           createCompanionCallback:
@@ -1237,10 +1343,13 @@ class $$GlobalSettingsTableTableManager
                 Value<ThemeSetting?> themeSetting = const Value.absent(),
                 Value<BrowserPreference?> browserPreference =
                     const Value.absent(),
+                Value<VisitFirstUnreadSetting?> visitFirstUnread =
+                    const Value.absent(),
                 Value<int> rowid = const Value.absent(),
               }) => GlobalSettingsCompanion.insert(
                 themeSetting: themeSetting,
                 browserPreference: browserPreference,
+                visitFirstUnread: visitFirstUnread,
                 rowid: rowid,
               ),
           withReferenceMapper:
diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart
index 749f60698c..a552f1679f 100644
--- a/lib/model/internal_link.dart
+++ b/lib/model/internal_link.dart
@@ -109,22 +109,43 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
   return result;
 }
 
-/// A [Narrow] from a given URL, on `store`'s realm.
+/// The result of parsing some URL within a Zulip realm,
+/// when the URL corresponds to some page in this app.
+sealed class InternalLink {
+  InternalLink({required this.realmUrl});
+
+  final Uri realmUrl;
+}
+
+/// The result of parsing some URL that points to a narrow on a Zulip realm,
+/// when the narrow is of a type that this app understands.
+class NarrowLink extends InternalLink {
+  NarrowLink(this.narrow, this.nearMessageId, {required super.realmUrl});
+
+  final Narrow narrow;
+  final int? nearMessageId;
+}
+
+/// Try to parse the given URL as a page in this app, on `store`'s realm.
 ///
 /// `url` must already be a result from [PerAccountStore.tryResolveUrl]
 /// on `store`.
 ///
-/// Returns `null` if any of the operator/operand pairs are invalid.
+/// Returns null if the URL isn't on this realm,
+/// or isn't a valid Zulip URL,
+/// or isn't currently supported as leading to a page in this app.
 ///
+/// In particular this will return null if `url` is a `/#narrow/…` URL
+/// and any of the operator/operand pairs are invalid.
 /// Since narrow links can combine operators in ways our [Narrow] type can't
 /// represent, this can also return null for valid narrow links.
 ///
 /// This can also return null for some valid narrow links that our Narrow
 /// type *could* accurately represent. We should try to understand these
-/// better, but some kinds will be rare, even unheard-of:
+/// better, but some kinds will be rare, even unheard-of.  For example:
 ///   #narrow/stream/1-announce/stream/1-announce (duplicated operator)
 // TODO(#252): handle all valid narrow links, returning a search narrow
-Narrow? parseInternalLink(Uri url, PerAccountStore store) {
+InternalLink? parseInternalLink(Uri url, PerAccountStore store) {
   if (!_isInternalLink(url, store.realmUrl)) return null;
 
   final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
@@ -155,7 +176,7 @@ bool _isInternalLink(Uri url, Uri realmUrl) {
   return (category, segments);
 }
 
-Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
+NarrowLink? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
   assert(segments.isNotEmpty);
   assert(segments.length.isEven);
 
@@ -164,6 +185,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
   ApiNarrowDm? dmElement;
   ApiNarrowWith? withElement;
   Set<IsOperand> isElementOperands = {};
+  int? nearMessageId;
 
   for (var i = 0; i < segments.length; i += 2) {
     final (operator, negated) = _parseOperator(segments[i]);
@@ -201,14 +223,18 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
         // It is fine to have duplicates of the same [IsOperand].
         isElementOperands.add(IsOperand.fromRawString(operand));
 
-      case _NarrowOperator.near: // TODO(#82): support for near
-        continue;
+      case _NarrowOperator.near:
+        if (nearMessageId != null) return null;
+        final messageId = int.tryParse(operand, radix: 10);
+        if (messageId == null) return null;
+        nearMessageId = messageId;
 
       case _NarrowOperator.unknown:
         return null;
     }
   }
 
+  final Narrow? narrow;
   if (isElementOperands.isNotEmpty) {
     if (streamElement != null || topicElement != null || dmElement != null || withElement != null) {
       return null;
@@ -216,9 +242,9 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
     if (isElementOperands.length > 1) return null;
     switch (isElementOperands.single) {
       case IsOperand.mentioned:
-        return const MentionsNarrow();
+        narrow = const MentionsNarrow();
       case IsOperand.starred:
-        return const StarredMessagesNarrow();
+        narrow = const StarredMessagesNarrow();
       case IsOperand.dm:
       case IsOperand.private:
       case IsOperand.alerted:
@@ -230,17 +256,20 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
     }
   } else if (dmElement != null) {
     if (streamElement != null || topicElement != null || withElement != null) return null;
-    return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
+    narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
   } else if (streamElement != null) {
     final streamId = streamElement.operand;
     if (topicElement != null) {
-      return TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
+      narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand);
     } else {
       if (withElement != null) return null;
-      return ChannelNarrow(streamId);
+      narrow = ChannelNarrow(streamId);
     }
+  } else {
+    return null;
   }
-  return null;
+
+  return NarrowLink(narrow, nearMessageId, realmUrl: store.realmUrl);
 }
 
 @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index 2617f18b68..458478f248 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -479,7 +479,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
   /// which might be made internally by this class in order to
   /// fetch the messages from scratch, e.g. after certain events.
   Anchor get anchor => _anchor;
-  final Anchor _anchor;
+  Anchor _anchor;
 
   void _register() {
     store.registerMessageList(this);
@@ -756,6 +756,20 @@ class MessageListView with ChangeNotifier, _MessageSequence {
     }
   }
 
+  /// Reset this view to start from the newest messages.
+  ///
+  /// This will set [anchor] to [AnchorCode.newest],
+  /// and cause messages to be re-fetched from scratch.
+  void jumpToEnd() {
+    assert(fetched);
+    assert(!haveNewest);
+    assert(anchor != AnchorCode.newest);
+    _anchor = AnchorCode.newest;
+    _reset();
+    notifyListeners();
+    fetchInitial();
+  }
+
   /// Add [outboxMessage] if it belongs to the view.
   void addOutboxMessage(OutboxMessage outboxMessage) {
     // TODO(#1441) implement this
diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart
index 9bfa74f627..4fcfc67a06 100644
--- a/lib/model/schema_versions.g.dart
+++ b/lib/model/schema_versions.g.dart
@@ -367,12 +367,87 @@ i1.GeneratedColumn<bool> _column_12(String aliasedName) =>
         'CHECK ("value" IN (0, 1))',
       ),
     );
+
+final class Schema7 extends i0.VersionedSchema {
+  Schema7({required super.database}) : super(version: 7);
+  @override
+  late final List<i1.DatabaseSchemaEntity> entities = [
+    globalSettings,
+    boolGlobalSettings,
+    accounts,
+  ];
+  late final Shape4 globalSettings = Shape4(
+    source: i0.VersionedTable(
+      entityName: 'global_settings',
+      withoutRowId: false,
+      isStrict: false,
+      tableConstraints: [],
+      columns: [_column_9, _column_10, _column_13],
+      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 Shape4 extends i0.VersionedTable {
+  Shape4({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> _column_13(String aliasedName) =>
+    i1.GeneratedColumn<String>(
+      'visit_first_unread',
+      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,
   required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
   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,
 }) {
   return (currentVersion, database) async {
     switch (currentVersion) {
@@ -401,6 +476,11 @@ i0.MigrationStepWithVersion migrationSteps({
         final migrator = i1.Migrator(database, schema);
         await from5To6(migrator, schema);
         return 6;
+      case 6:
+        final schema = Schema7(database: database);
+        final migrator = i1.Migrator(database, schema);
+        await from6To7(migrator, schema);
+        return 7;
       default:
         throw ArgumentError.value('Unknown migration from $currentVersion');
     }
@@ -413,6 +493,7 @@ i1.OnUpgrade stepByStep({
   required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
   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,
 }) => i0.VersionedSchema.stepByStepHelper(
   step: migrationSteps(
     from1To2: from1To2,
@@ -420,5 +501,6 @@ i1.OnUpgrade stepByStep({
     from3To4: from3To4,
     from4To5: from4To5,
     from5To6: from5To6,
+    from6To7: from6To7,
   ),
 );
diff --git a/lib/model/settings.dart b/lib/model/settings.dart
index 5fd2fec9f8..d3393292a6 100644
--- a/lib/model/settings.dart
+++ b/lib/model/settings.dart
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import 'binding.dart';
 import 'database.dart';
+import 'narrow.dart';
 import 'store.dart';
 
 /// The user's choice of visual theme for the app.
@@ -45,6 +46,27 @@ enum BrowserPreference {
   external,
 }
 
+/// The user's choice of when to open a message list at their first unread,
+/// rather than at the newest message.
+///
+/// This setting has no effect when navigating to a specific message:
+/// in that case the message list opens at that message,
+/// regardless of this setting.
+enum VisitFirstUnreadSetting {
+  /// Always go to the first unread, rather than the newest message.
+  always,
+
+  /// Go to the first unread in conversations,
+  /// and the newest in interleaved views.
+  conversations,
+
+  /// Always go to the newest message, rather than the first unread.
+  never;
+
+  /// The effective value of this setting if the user hasn't set it.
+  static VisitFirstUnreadSetting _default = conversations;
+}
+
 /// A general category of account-independent setting the user might set.
 ///
 /// Different kinds of settings call for different treatment in the UI,
@@ -119,7 +141,7 @@ enum BoolGlobalSetting {
 
   // Former settings which might exist in the database,
   // whose names should therefore not be reused:
-  // (this list is empty so far)
+  //   openFirstUnread  // v0.0.30
   ;
 
   const BoolGlobalSetting(this.type, this.default_);
@@ -228,6 +250,33 @@ class GlobalSettingsStore extends ChangeNotifier {
     }
   }
 
+  /// The user's choice of [VisitFirstUnreadSetting], applying our default.
+  ///
+  /// See also [shouldVisitFirstUnread] and [setVisitFirstUnread].
+  VisitFirstUnreadSetting get visitFirstUnread {
+    return _data.visitFirstUnread ?? VisitFirstUnreadSetting._default;
+  }
+
+  /// Set [visitFirstUnread], persistently for future runs of the app.
+  Future<void> setVisitFirstUnread(VisitFirstUnreadSetting value) async {
+    await _update(GlobalSettingsCompanion(visitFirstUnread: Value(value)));
+  }
+
+  /// The value that [visitFirstUnread] works out to for the given narrow.
+  bool shouldVisitFirstUnread({required Narrow narrow}) {
+    return switch (visitFirstUnread) {
+      VisitFirstUnreadSetting.always => true,
+      VisitFirstUnreadSetting.never => false,
+      VisitFirstUnreadSetting.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/notifications/display.dart b/lib/notifications/display.dart
index 081a2cf633..6e2585e135 100644
--- a/lib/notifications/display.dart
+++ b/lib/notifications/display.dart
@@ -513,7 +513,7 @@ class NotificationDisplayManager {
 
     return MessageListPage.buildRoute(
       accountId: account.id,
-      // TODO(#82): Open at specific message, not just conversation
+      // TODO(#1565): Open at specific message, not just conversation
       narrow: payload.narrow);
   }
 
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 62801ab867..44411434fd 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1536,15 +1536,18 @@ void _launchUrl(BuildContext context, String urlString) async {
     return;
   }
 
-  final internalNarrow = parseInternalLink(url, store);
-  if (internalNarrow != null) {
-    unawaited(Navigator.push(context,
-      MessageListPage.buildRoute(context: context,
-        narrow: internalNarrow)));
-    return;
+  final internalLink = parseInternalLink(url, store);
+  assert(internalLink == null || internalLink.realmUrl == store.realmUrl);
+  switch (internalLink) {
+    case NarrowLink():
+      unawaited(Navigator.push(context,
+        MessageListPage.buildRoute(context: context,
+          narrow: internalLink.narrow,
+          initAnchorMessageId: internalLink.nearMessageId)));
+
+    case null:
+      await PlatformActions.launchUrl(context, url);
   }
-
-  await PlatformActions.launchUrl(context, url);
 }
 
 /// Like [Image.network], but includes [authHeader] if [src] is on-realm.
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index aa9684b7f2..513043fea6 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -141,12 +141,17 @@ abstract class MessageListPageState {
 }
 
 class MessageListPage extends StatefulWidget {
-  const MessageListPage({super.key, required this.initNarrow});
+  const MessageListPage({
+    super.key,
+    required this.initNarrow,
+    this.initAnchorMessageId,
+  });
 
   static AccountRoute<void> buildRoute({int? accountId, BuildContext? context,
-      required Narrow narrow}) {
+      required Narrow narrow, int? initAnchorMessageId}) {
     return MaterialAccountWidgetRoute(accountId: accountId, context: context,
-      page: MessageListPage(initNarrow: narrow));
+      page: MessageListPage(
+        initNarrow: narrow, initAnchorMessageId: initAnchorMessageId));
   }
 
   /// The [MessageListPageState] above this context in the tree.
@@ -162,6 +167,7 @@ class MessageListPage extends StatefulWidget {
   }
 
   final Narrow initNarrow;
+  final int? initAnchorMessageId; // TODO(#1564) highlight target upon load
 
   @override
   State<MessageListPage> createState() => _MessageListPageState();
@@ -240,6 +246,15 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
         actions.add(_TopicListButton(streamId: streamId));
     }
 
+    final Anchor initAnchor;
+    if (widget.initAnchorMessageId != null) {
+      initAnchor = NumericAnchor(widget.initAnchorMessageId!);
+    } else {
+      final globalSettings = GlobalStoreWidget.settingsOf(context);
+      final useFirstUnread = globalSettings.shouldVisitFirstUnread(narrow: narrow);
+      initAnchor = useFirstUnread ? AnchorCode.firstUnread : AnchorCode.newest;
+    }
+
     // Insert a PageRoot here, to provide a context that can be used for
     // MessageListPage.ancestorOf.
     return PageRoot(child: Scaffold(
@@ -259,7 +274,8 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
       //   we matched to the Figma in 21dbae120. See another frame, which uses that:
       //     https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev
       body: Builder(
-        builder: (BuildContext context) => Column(
+        builder: (BuildContext context) {
+          return Column(
           // Children are expected to take the full horizontal space
           // and handle the horizontal device insets.
           // The bottom inset should be handled by the last child only.
@@ -279,11 +295,13 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
                 child: MessageList(
                   key: _messageListKey,
                   narrow: narrow,
+                  initAnchor: initAnchor,
                   onNarrowChanged: _narrowChanged,
                 ))),
             if (ComposeBox.hasComposeBox(narrow))
               ComposeBox(key: _composeBoxKey, narrow: narrow)
-          ]))));
+          ]);
+        })));
   }
 }
 
@@ -479,9 +497,15 @@ const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMes
 /// When there is no [ComposeBox], also takes responsibility
 /// for dealing with the bottom inset.
 class MessageList extends StatefulWidget {
-  const MessageList({super.key, required this.narrow, required this.onNarrowChanged});
+  const MessageList({
+    super.key,
+    required this.narrow,
+    required this.initAnchor,
+    required this.onNarrowChanged,
+  });
 
   final Narrow narrow;
+  final Anchor initAnchor;
   final void Function(Narrow newNarrow) onNarrowChanged;
 
   @override
@@ -504,8 +528,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
 
   @override
   void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages
+    final anchor = _model == null ? widget.initAnchor : _model!.anchor;
     _model?.dispose();
-    _initModel(PerAccountStoreWidget.of(context));
+    _initModel(PerAccountStoreWidget.of(context), anchor);
   }
 
   @override
@@ -516,10 +541,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
     super.dispose();
   }
 
-  void _initModel(PerAccountStore store) {
-    // TODO(#82): get anchor as page/route argument, instead of using newest
-    // TODO(#80): default to anchor firstUnread, instead of newest
-    final anchor = AnchorCode.newest;
+  void _initModel(PerAccountStore store, Anchor anchor) {
     _model = MessageListView.init(store: store,
       narrow: widget.narrow, anchor: anchor);
     model.addListener(_modelChanged);
@@ -537,6 +559,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
       //   redirected us to the new location of the operand message ID.
       widget.onNarrowChanged(model.narrow);
     }
+    // TODO when model reset, reset scroll
     setState(() {
       // The actual state lives in the [MessageListView] model.
       // This method was called because that just changed.
@@ -568,6 +591,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
       //   still not yet updated to account for the newly-added messages.
       model.fetchOlder();
     }
+    if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {
+      model.fetchNewer();
+    }
   }
 
   void _scrollChanged() {
@@ -618,6 +644,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
                   //   MessageList's dartdoc.
                   child: SafeArea(
                     child: ScrollToBottomButton(
+                      model: model,
                       scrollController: scrollController,
                       visible: _scrollToBottomVisible))),
               ])))));
@@ -753,13 +780,21 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
   }
 
   Widget _buildEndCap() {
-    return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
-      TypingStatusWidget(narrow: widget.narrow),
-      MarkAsReadWidget(narrow: widget.narrow),
-      // To reinforce that the end of the feed has been reached:
-      //   https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
-      const SizedBox(height: 36),
-    ]);
+    if (model.haveNewest) {
+      return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
+        TypingStatusWidget(narrow: widget.narrow),
+        // TODO perhaps offer mark-as-read even when not done fetching?
+        MarkAsReadWidget(narrow: widget.narrow),
+        // To reinforce that the end of the feed has been reached:
+        //   https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
+        const SizedBox(height: 36),
+      ]);
+    } else if (model.busyFetchingMore) {
+      // See [_buildStartCap] for why this condition shows a loading indicator.
+      return const _MessageListLoadingMore();
+    } else {
+      return SizedBox.shrink();
+    }
   }
 
   Widget _buildItem(MessageListItem data) {
@@ -809,13 +844,40 @@ class _MessageListLoadingMore extends StatelessWidget {
 }
 
 class ScrollToBottomButton extends StatelessWidget {
-  const ScrollToBottomButton({super.key, required this.scrollController, required this.visible});
+  const ScrollToBottomButton({
+    super.key,
+    required this.model,
+    required this.scrollController,
+    required this.visible,
+  });
 
-  final ValueNotifier<bool> visible;
+  final MessageListView model;
   final MessageListScrollController scrollController;
+  final ValueNotifier<bool> visible;
 
   void _scrollToBottom() {
-    scrollController.position.scrollToEnd();
+    if (model.haveNewest) {
+      // Scrolling smoothly from here to the bottom won't require any requests
+      // to the server.
+      // It also probably isn't *that* far away: the user must have scrolled
+      // here from there (or from near enough that a fetch reached there),
+      // so scrolling back there -- at top speed -- shouldn't take too long.
+      // Go for it.
+      scrollController.position.scrollToEnd();
+    } else {
+      // This message list doesn't have the messages for the bottom of history.
+      // There could be quite a lot of history between here and there --
+      // for example, at first unread in the combined feed or a busy channel,
+      // for a user who has some old unreads going back months and years.
+      // In that case trying to scroll smoothly to the bottom is hopeless.
+      //
+      // Given that there were at least 100 messages between this message list's
+      // initial anchor and the end of history (or else `fetchInitial` would
+      // have reached the end at the outset), that situation is very likely.
+      // Even if the end is close by, it's at least one fetch away.
+      // Instead of scrolling, jump to the end, which is always just one fetch.
+      model.jumpToEnd();
+    }
   }
 
   @override
diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart
index a96fb82928..449be11313 100644
--- a/lib/widgets/settings.dart
+++ b/lib/widgets/settings.dart
@@ -23,6 +23,7 @@ class SettingsPage extends StatelessWidget {
       body: Column(children: [
         const _ThemeSetting(),
         const _BrowserPreferenceSetting(),
+        const _VisitFirstUnreadSetting(),
         if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty)
           ListTile(
             title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle),
@@ -85,6 +86,70 @@ class _BrowserPreferenceSetting extends StatelessWidget {
   }
 }
 
+class _VisitFirstUnreadSetting extends StatelessWidget {
+  const _VisitFirstUnreadSetting();
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    final globalSettings = GlobalStoreWidget.settingsOf(context);
+    return ListTile(
+      title: Text(zulipLocalizations.initialAnchorSettingTitle),
+      subtitle: Text(VisitFirstUnreadSettingPage._valueDisplayName(
+        globalSettings.visitFirstUnread, zulipLocalizations: zulipLocalizations)),
+      onTap: () => Navigator.push(context,
+        VisitFirstUnreadSettingPage.buildRoute()));
+  }
+}
+
+class VisitFirstUnreadSettingPage extends StatelessWidget {
+  const VisitFirstUnreadSettingPage({super.key});
+
+  static WidgetRoute<void> buildRoute() {
+    return MaterialWidgetRoute(page: const VisitFirstUnreadSettingPage());
+  }
+
+  static String _valueDisplayName(VisitFirstUnreadSetting value, {
+    required ZulipLocalizations zulipLocalizations,
+  }) {
+    return switch (value) {
+      VisitFirstUnreadSetting.always =>
+        zulipLocalizations.initialAnchorSettingFirstUnreadAlways,
+      VisitFirstUnreadSetting.conversations =>
+        zulipLocalizations.initialAnchorSettingFirstUnreadConversations,
+      VisitFirstUnreadSetting.never =>
+        zulipLocalizations.initialAnchorSettingNewestAlways,
+    };
+  }
+
+  void _handleChange(BuildContext context, VisitFirstUnreadSetting? value) {
+    if (value == null) return; // TODO(log); can this actually happen? how?
+    final globalSettings = GlobalStoreWidget.settingsOf(context);
+    globalSettings.setVisitFirstUnread(value);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    final globalSettings = GlobalStoreWidget.settingsOf(context);
+    return Scaffold(
+      appBar: AppBar(title: Text(zulipLocalizations.initialAnchorSettingTitle)),
+      body: Column(children: [
+        ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)),
+        for (final value in VisitFirstUnreadSetting.values)
+          RadioListTile.adaptive(
+            title: Text(_valueDisplayName(value,
+              zulipLocalizations: zulipLocalizations)),
+            value: value,
+            // TODO(#1545) stop using the deprecated members
+            // ignore: deprecated_member_use
+            groupValue: globalSettings.visitFirstUnread,
+            // ignore: deprecated_member_use
+            onChanged: (newValue) => _handleChange(context, newValue)),
+      ]));
+  }
+}
+
 class ExperimentalFeaturesPage extends StatelessWidget {
   const ExperimentalFeaturesPage({super.key});
 


Test patch:
diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart
index 611cc3ece0..824def8cc2 100644
--- a/test/model/internal_link_test.dart
+++ b/test/model/internal_link_test.dart
@@ -160,7 +160,14 @@ void main() {
         test(urlString, () async {
           final store = await setupStore(realmUrl: realmUrl, streams: streams, users: users);
           final url = store.tryResolveUrl(urlString)!;
-          check(parseInternalLink(url, store)).equals(expected);
+          final result = parseInternalLink(url, store);
+          if (expected == null) {
+            check(result).isNull();
+          } else {
+            check(result).isA<NarrowLink>()
+              ..realmUrl.equals(realmUrl)
+              ..narrow.equals(expected);
+          }
         });
       }
     }
@@ -258,6 +265,9 @@ void main() {
           final url = store.tryResolveUrl(urlString)!;
           final result = parseInternalLink(url, store);
           check(result != null).equals(expected);
+          if (result != null) {
+            check(result).realmUrl.equals(realmUrl);
+          }
         });
       }
     }
@@ -370,6 +380,8 @@ void main() {
       }
     });
 
+    // TODO(#1570): test parsing /near/ operator
+
     group('unexpected link shapes are rejected', () {
       final testCases = [
         ('/#narrow/stream/name/topic/',           null), // missing operand
@@ -564,3 +576,11 @@ void main() {
     });
   });
 }
+
+extension InternalLinkChecks on Subject<InternalLink> {
+  Subject<Uri> get realmUrl => has((x) => x.realmUrl, 'realmUrl');
+}
+
+extension NarrowLinkChecks on Subject<NarrowLink> {
+  Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
+}
diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart
index d58de664d8..0eb30c1cbb 100644
--- a/test/model/message_list_test.dart
+++ b/test/model/message_list_test.dart
@@ -566,6 +566,8 @@ void main() {
     });
   });
 
+  // TODO(#1569): test jumpToEnd
+
   group('MessageEvent', () {
     test('in narrow', () async {
       final stream = eg.stream();
diff --git a/test/model/schemas/drift_schema_v7.json b/test/model/schemas/drift_schema_v7.json
new file mode 100644
index 0000000000..28ceaac619
--- /dev/null
+++ b/test/model/schemas/drift_schema_v7.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"}}],"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 d59002bf56..87de9194d3 100644
--- a/test/model/schemas/schema.dart
+++ b/test/model/schemas/schema.dart
@@ -9,6 +9,7 @@ import 'schema_v3.dart' as v3;
 import 'schema_v4.dart' as v4;
 import 'schema_v5.dart' as v5;
 import 'schema_v6.dart' as v6;
+import 'schema_v7.dart' as v7;
 
 class GeneratedHelper implements SchemaInstantiationHelper {
   @override
@@ -26,10 +27,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
         return v5.DatabaseAtV5(db);
       case 6:
         return v6.DatabaseAtV6(db);
+      case 7:
+        return v7.DatabaseAtV7(db);
       default:
         throw MissingSchemaException(version, versions);
     }
   }
 
-  static const versions = const [1, 2, 3, 4, 5, 6];
+  static const versions = const [1, 2, 3, 4, 5, 6, 7];
 }
diff --git a/test/model/schemas/schema_v7.dart b/test/model/schemas/schema_v7.dart
new file mode 100644
index 0000000000..dd3951b800
--- /dev/null
+++ b/test/model/schemas/schema_v7.dart
@@ -0,0 +1,942 @@
+// 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,
+  );
+  @override
+  List<GeneratedColumn> get $columns => [
+    themeSetting,
+    browserPreference,
+    visitFirstUnread,
+  ];
+  @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'],
+      ),
+    );
+  }
+
+  @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;
+  const GlobalSettingsData({
+    this.themeSetting,
+    this.browserPreference,
+    this.visitFirstUnread,
+  });
+  @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);
+    }
+    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),
+    );
+  }
+
+  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']),
+    );
+  }
+  @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),
+    };
+  }
+
+  GlobalSettingsData copyWith({
+    Value<String?> themeSetting = const Value.absent(),
+    Value<String?> browserPreference = const Value.absent(),
+    Value<String?> visitFirstUnread = 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,
+  );
+  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,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('GlobalSettingsData(')
+          ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference, ')
+          ..write('visitFirstUnread: $visitFirstUnread')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode =>
+      Object.hash(themeSetting, browserPreference, visitFirstUnread);
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is GlobalSettingsData &&
+          other.themeSetting == this.themeSetting &&
+          other.browserPreference == this.browserPreference &&
+          other.visitFirstUnread == this.visitFirstUnread);
+}
+
+class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
+  final Value<String?> themeSetting;
+  final Value<String?> browserPreference;
+  final Value<String?> visitFirstUnread;
+  final Value<int> rowid;
+  const GlobalSettingsCompanion({
+    this.themeSetting = const Value.absent(),
+    this.browserPreference = const Value.absent(),
+    this.visitFirstUnread = 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.rowid = const Value.absent(),
+  });
+  static Insertable<GlobalSettingsData> custom({
+    Expression<String>? themeSetting,
+    Expression<String>? browserPreference,
+    Expression<String>? visitFirstUnread,
+    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 (rowid != null) 'rowid': rowid,
+    });
+  }
+
+  GlobalSettingsCompanion copyWith({
+    Value<String?>? themeSetting,
+    Value<String?>? browserPreference,
+    Value<String?>? visitFirstUnread,
+    Value<int>? rowid,
+  }) {
+    return GlobalSettingsCompanion(
+      themeSetting: themeSetting ?? this.themeSetting,
+      browserPreference: browserPreference ?? this.browserPreference,
+      visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread,
+      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 (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('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 DatabaseAtV7 extends GeneratedDatabase {
+  DatabaseAtV7(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 => 7;
+}
diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart
index ad739f5d4b..89956323e2 100644
--- a/test/model/settings_test.dart
+++ b/test/model/settings_test.dart
@@ -77,6 +77,9 @@ void main() {
     // TODO integration tests with sqlite
   });
 
+  // TODO(#1571) test visitFirstUnread applies default
+  // TODO(#1571) test shouldVisitFirstUnread
+
   group('getBool/setBool', () {
     test('get from default', () {
       final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings;
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index a788225aac..b5150a54ee 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -1032,6 +1032,8 @@ void main() {
         .page.isA<MessageListPage>().initNarrow.equals(const ChannelNarrow(1));
     });
 
+    // TODO(#1570): test links with /near/ go to the specific message
+
     testWidgets('invalid internal links are opened in browser', (tester) async {
       // Link is invalid due to `topic` operator missing an operand.
       final pushedRoutes = await prepare(tester,
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index 3b3b01b323..5ba2712e76 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -59,6 +59,7 @@ void main() {
     bool foundOldest = true,
     int? messageCount,
     List<Message>? messages,
+    GetMessagesResult? fetchResult,
     List<ZulipStream>? streams,
     List<User>? users,
     List<Subscription>? subscriptions,
@@ -83,12 +84,17 @@ void main() {
     // prepare message list data
     await store.addUser(eg.selfUser);
     await store.addUsers(users ?? []);
-    assert((messageCount == null) != (messages == null));
-    messages ??= List.generate(messageCount!, (index) {
-      return eg.streamMessage(sender: eg.selfUser);
-    });
-    connection.prepare(json:
-      eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson());
+    if (fetchResult != null) {
+      assert(foundOldest && messageCount == null && messages == null);
+    } else {
+      assert((messageCount == null) != (messages == null));
+      messages ??= List.generate(messageCount!, (index) {
+        return eg.streamMessage(sender: eg.selfUser);
+      });
+      fetchResult = eg.newestGetMessagesResult(
+        foundOldest: foundOldest, messages: messages);
+    }
+    connection.prepare(json: fetchResult.toJson());
 
     await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id,
       skipAssertAccountExists: skipAssertAccountExists,
@@ -368,6 +374,11 @@ void main() {
   });
 
   group('fetch initial batch of messages', () {
+    // TODO(#1571): test effect of visitFirstUnread setting
+    // TODO(#1569): test effect of initAnchorMessageId
+    // TODO(#1569): test that after jumpToEnd, then new store causing new fetch,
+    //   new post-jump anchor prevails over initAnchorMessageId
+
     group('topic permalink', () {
       final someStream = eg.stream();
       const someTopic = 'some topic';
@@ -402,7 +413,7 @@ void main() {
           ..url.path.equals('/api/v1/messages')
           ..url.queryParameters.deepEquals({
             'narrow': jsonEncode(narrow.apiEncode()),
-            'anchor': AnchorCode.newest.toJson(),
+            'anchor': AnchorCode.firstUnread.toJson(),
             'num_before': kMessageListFetchBatchSize.toString(),
             'num_after': kMessageListFetchBatchSize.toString(),
             'allow_empty_topic_name': 'true',
@@ -435,7 +446,7 @@ void main() {
           ..url.path.equals('/api/v1/messages')
           ..url.queryParameters.deepEquals({
             'narrow': jsonEncode(narrow.apiEncode()),
-            'anchor': AnchorCode.newest.toJson(),
+            'anchor': AnchorCode.firstUnread.toJson(),
             'num_before': kMessageListFetchBatchSize.toString(),
             'num_after': kMessageListFetchBatchSize.toString(),
             'allow_empty_topic_name': 'true',
@@ -445,6 +456,10 @@ void main() {
   });
 
   group('fetch older messages on scroll', () {
+    // TODO(#1569): test fetch newer messages on scroll, too;
+    //   in particular test it happens even when near top as well as bottom
+    //   (because may have haveOldest true but haveNewest false)
+
     int? itemCount(WidgetTester tester) =>
       findScrollView(tester).semanticChildCount;
 
@@ -656,6 +671,8 @@ void main() {
       check(isButtonVisible(tester)).equals(false);
     });
 
+    // TODO(#1569): test choice of jumpToEnd vs. scrollToEnd
+
     testWidgets('scrolls at reasonable, constant speed', (tester) async {
       const maxSpeed = 8000.0;
       const distance = 40000.0;
@@ -692,6 +709,63 @@ void main() {
     });
   });
 
+  // TODO test markers at start of list (`_buildStartCap`)
+
+  group('markers at end of list', () {
+    final findLoadingIndicator = find.byType(CircularProgressIndicator);
+
+    testWidgets('spacer when have newest', (tester) async {
+      final messages = List.generate(10,
+        (i) => eg.streamMessage(content: '<p>message $i</p>'));
+      await setupMessageListPage(tester, narrow: CombinedFeedNarrow(),
+        fetchResult: eg.nearGetMessagesResult(anchor: messages.last.id,
+          foundOldest: true, foundNewest: true, messages: messages));
+      check(findMessageListScrollController(tester)!.position)
+        .extentAfter.equals(0);
+
+      // There's no loading indicator.
+      check(findLoadingIndicator).findsNothing();
+      // The last message is spaced above the bottom of the viewport.
+      check(tester.getRect(find.text('message 9')))
+        .bottom..isGreaterThan(400)..isLessThan(570);
+    });
+
+    testWidgets('loading indicator displaces spacer etc.', (tester) async {
+      await setupMessageListPage(tester, narrow: CombinedFeedNarrow(),
+        skipPumpAndSettle: true,
+        // TODO(#1569) fix realism of this data: foundNewest false should mean
+        //   some messages found after anchor (and then we might need to scroll
+        //   to cause fetching newer messages).
+        fetchResult: eg.nearGetMessagesResult(anchor: 1000,
+          foundOldest: true, foundNewest: false,
+          messages: List.generate(10,
+            (i) => eg.streamMessage(id: 100 + i, content: '<p>message $i</p>'))));
+      await tester.pump();
+
+      // The message list will immediately start fetching newer messages.
+      connection.prepare(json: eg.newerGetMessagesResult(
+        anchor: 109, foundNewest: true, messages: List.generate(100,
+          (i) => eg.streamMessage(id: 110 + i))).toJson());
+      await tester.pump(Duration(milliseconds: 10));
+      await tester.pump();
+
+      // There's a loading indicator.
+      check(findLoadingIndicator).findsOne();
+      // It's at the bottom.
+      check(findMessageListScrollController(tester)!.position)
+        .extentAfter.equals(0);
+      final loadingIndicatorRect = tester.getRect(findLoadingIndicator);
+      check(loadingIndicatorRect).bottom.isGreaterThan(575);
+      // The last message is shortly above it; no spacer or anything else.
+      check(tester.getRect(find.text('message 9')))
+        .bottom.isGreaterThan(loadingIndicatorRect.top - 36); // TODO(#1569) where's this space going?
+      await tester.pumpAndSettle();
+    });
+
+    // TODO(#1569) test no typing status or mark-read button when not haveNewest
+    //   (even without loading indicator)
+  });
+
   group('TypingStatusWidget', () {
     final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
     final finder = find.descendant(
diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart
index 46df165ecc..96fd62feeb 100644
--- a/test/widgets/settings_test.dart
+++ b/test/widgets/settings_test.dart
@@ -127,6 +127,8 @@ void main() {
     }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
   });
 
+  // TODO(#1571): test visitFirstUnread setting UI
+
   // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings
   //   Or maybe not; after all, it's a developer-facing feature, so
   //   should be low risk.