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

Issue:
msglist: Friendlier placeholder text when narrow has no messages
When a message list has no messages, we currently just show "No earlier messages". Screenshots for the narrows currently supported in the message-list page:

| Combined feed | Channel | Topic | DM (1:1) | DM (self) | DM (group) | Starred messages | Mentions |
| --- | --- | --- | --- | --- | --- | --- | --- |
| <img width="559" alt="Image" src="https://github.com/user-attachments/assets/759d20f9-924b-459e-946c-d2aedfe3d156" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/024d2264-51f0-45ca-9c1a-8f2ac9bf051c" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/8e50e705-82b9-4bb2-8cab-05b4c4178043" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/c5b896c1-f948-4237-a38a-97521499d319" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/e69a85bf-28d9-494b-a5c2-64a280ef19f0" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/7967902e-835e-4aae-ab05-1339f732045b" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/c866f1cc-5286-444a-be34-df4f7661fed0" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/f5e254e3-c05a-4d55-978e-b65b750aae4f" /> | <img width="559" alt="Image" src="https://github.com/user-attachments/assets/74fab689-b958-4e7c-937a-5ff6c10329f4" /> |

This issue is to show a simple message for all message-list pages. Conditional per-narrow logic is tracked in #1653 for later.

For the design, let's aim for consistency with #1552, which is based on the Figma (but excludes some graphics; #1551).

cc @alya


Discussion hints:
"There are no messages here." is the default placeholder on web, which seems more clear ("earlier than what?").


But if it's comparably easy to do specific messages depending on the view, I would just copy whatever we have on web. If we don't like some of them, we can discuss that and change the web one too, but it might as well be consistent.
Makes sense. This may interact with Greg's work on #82 / #80, so I'll hold off on this for now. (Normally I'd go ahead because new-DM #127 will make this more visible.)

Pull request: msglist: Friendlier placeholder text when narrow has no messages (simple version)
Splitting this out from #1640, so it can be merged soon.

Fixes #1555.

For now, the text simply says "There are no messages here." We'll add per-narrow logic later, but this is an improvement over the current appearance which just says "No earlier messages." (Earlier than what?)

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

Fix patch:
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index d7fd14303b..9b6c5f6532 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -530,6 +530,10 @@
       "others": {"type": "String", "example": "Alice, Bob"}
     }
   },
+  "emptyMessageList": "There are no messages here.",
+  "@emptyMessageList": {
+    "description": "Placeholder for some message-list pages when there are no messages."
+  },
   "messageListGroupYouWithYourself": "Messages with yourself",
   "@messageListGroupYouWithYourself": {
     "description": "Message list recipient header for a DM group that only includes yourself."
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 3887e381a2..85e6b1bbe9 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -845,6 +845,12 @@ abstract class ZulipLocalizations {
   /// **'DMs with {others}'**
   String dmsWithOthersPageTitle(String others);
 
+  /// Placeholder for some message-list pages when there are no messages.
+  ///
+  /// In en, this message translates to:
+  /// **'There are no messages here.'**
+  String get emptyMessageList;
+
   /// Message list recipient header for a DM group that only includes yourself.
   ///
   /// 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 a4e972abc4..29eeaa84b7 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -434,6 +434,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
     return 'DMs with $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messages with yourself';
 
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 8ca5d081e3..a54262e964 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -449,6 +449,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
     return 'DNs mit $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Nachrichten mit dir selbst';
 
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index f065d5f59c..9cca30a3e1 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -434,6 +434,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
     return 'DMs with $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messages with yourself';
 
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 8fc5df768b..451c959345 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -445,6 +445,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
     return 'MD con $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messaggi con te stesso';
 
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index eccff7ea5d..ccdfad4001 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -434,6 +434,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
     return 'DMs with $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messages with yourself';
 
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 69557352b5..bd708f0b7c 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -434,6 +434,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
     return 'DMs with $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messages with yourself';
 
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 21e8f3e478..e744825644 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -443,6 +443,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
     return 'DM z $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Zapiski na własne konto';
 
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index f4f25c7d20..a980357a8e 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -443,6 +443,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
     return 'ЛС с $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Сообщения с собой';
 
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 4558dcd872..afb6d05654 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -434,6 +434,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
     return 'DMs with $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messages with yourself';
 
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index e6f4275f77..2cb377a57b 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -455,6 +455,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
     return 'Neposredna sporočila z $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Sporočila sebi';
 
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 98ba4b11e1..ab35988c35 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -445,6 +445,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
     return 'Особисті повідомлення з $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Повідомлення з собою';
 
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index b15d029eb1..58330028ad 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -434,6 +434,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
     return 'DMs with $others';
   }
 
+  @override
+  String get emptyMessageList => 'There are no messages here.';
+
   @override
   String get messageListGroupYouWithYourself => 'Messages with yourself';
 
diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index 9a0850e0b9..88d3bf5d2e 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -148,40 +148,6 @@ class _HomePageState extends State<HomePage> {
   }
 }
 
-/// A "no content here" message, for the Inbox, Subscriptions, and DMs pages.
-///
-/// This should go near the root of the "page body"'s widget subtree.
-/// In particular, it handles the horizontal device insets.
-/// (The vertical insets are handled externally, by the app bar and bottom nav.)
-class PageBodyEmptyContentPlaceholder extends StatelessWidget {
-  const PageBodyEmptyContentPlaceholder({super.key, required this.message});
-
-  final String message;
-
-  @override
-  Widget build(BuildContext context) {
-    final designVariables = DesignVariables.of(context);
-
-    return SafeArea(
-      minimum: EdgeInsets.symmetric(horizontal: 24),
-      child: Padding(
-        padding: EdgeInsets.only(top: 48, bottom: 16),
-        child: Align(
-          alignment: Alignment.topCenter,
-          // TODO leading and trailing elements, like in Figma (given as SVGs):
-          //   https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev
-          child: Text(
-            textAlign: TextAlign.center,
-            style: TextStyle(
-              color: designVariables.labelSearchPrompt,
-              fontSize: 17,
-              height: 23 / 17,
-            ).merge(weightVariableTextStyle(context, wght: 500)),
-            message))));
-  }
-}
-
-
 const kTryAnotherAccountWaitPeriod = Duration(seconds: 5);
 
 class _LoadingPlaceholderPage extends StatefulWidget {
diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart
index 7f101a81ce..d00cabb9dc 100644
--- a/lib/widgets/inbox.dart
+++ b/lib/widgets/inbox.dart
@@ -6,9 +6,9 @@ import '../model/narrow.dart';
 import '../model/recent_dm_conversations.dart';
 import '../model/unreads.dart';
 import 'action_sheet.dart';
-import 'home.dart';
 import 'icons.dart';
 import 'message_list.dart';
+import 'page.dart';
 import 'sticky_header.dart';
 import 'store.dart';
 import 'text.dart';
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index f0ecbe10a1..b14a9fe5ce 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -859,8 +859,15 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
 
   @override
   Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+
     if (!model.fetched) return const Center(child: CircularProgressIndicator());
 
+    if (model.items.isEmpty && model.haveNewest && model.haveOldest) {
+      return PageBodyEmptyContentPlaceholder(
+        message: zulipLocalizations.emptyMessageList);
+    }
+
     // Pad the left and right insets, for small devices in landscape.
     return SafeArea(
       // Don't let this be the place we pad the bottom inset. When there's
diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart
index a2c6fe52a1..35bdf34923 100644
--- a/lib/widgets/page.dart
+++ b/lib/widgets/page.dart
@@ -2,6 +2,8 @@
 import 'package:flutter/material.dart';
 
 import 'store.dart';
+import 'text.dart';
+import 'theme.dart';
 
 /// An [InheritedWidget] for near the root of a page's widget subtree,
 /// providing its [BuildContext].
@@ -210,3 +212,40 @@ class LoadingPlaceholderPage extends StatelessWidget {
     );
   }
 }
+
+/// A "no content here" message for when a page has no content to show.
+///
+/// Suitable for the inbox, the message-list page, etc.
+///
+/// This handles the horizontal device insets
+/// and the bottom inset when needed (in a message list with no compose box).
+/// The top inset is handled externally by the app bar.
+// TODO(#311) If the message list gets a bottom nav, the bottom inset will
+//   always be handled externally too; simplify implementation and dartdoc.
+class PageBodyEmptyContentPlaceholder extends StatelessWidget {
+  const PageBodyEmptyContentPlaceholder({super.key, required this.message});
+
+  final String message;
+
+  @override
+  Widget build(BuildContext context) {
+    final designVariables = DesignVariables.of(context);
+
+    return SafeArea(
+      minimum: EdgeInsets.fromLTRB(24, 0, 24, 16),
+      child: Padding(
+        padding: EdgeInsets.only(top: 48),
+        child: Align(
+          alignment: Alignment.topCenter,
+          // TODO leading and trailing elements, like in Figma (given as SVGs):
+          //   https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev
+          child: Text(
+            textAlign: TextAlign.center,
+            style: TextStyle(
+              color: designVariables.labelSearchPrompt,
+              fontSize: 17,
+              height: 23 / 17,
+            ).merge(weightVariableTextStyle(context, wght: 500)),
+            message))));
+  }
+}
diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart
index 5758a39d76..97c53ac4b1 100644
--- a/lib/widgets/recent_dm_conversations.dart
+++ b/lib/widgets/recent_dm_conversations.dart
@@ -5,10 +5,10 @@ import '../model/narrow.dart';
 import '../model/recent_dm_conversations.dart';
 import '../model/unreads.dart';
 import 'content.dart';
-import 'home.dart';
 import 'icons.dart';
 import 'message_list.dart';
 import 'new_dm_sheet.dart';
+import 'page.dart';
 import 'store.dart';
 import 'text.dart';
 import 'theme.dart';
diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart
index 8a7bd9b9b5..ff3db94391 100644
--- a/lib/widgets/subscription_list.dart
+++ b/lib/widgets/subscription_list.dart
@@ -5,9 +5,9 @@ import '../generated/l10n/zulip_localizations.dart';
 import '../model/narrow.dart';
 import '../model/unreads.dart';
 import 'action_sheet.dart';
-import 'home.dart';
 import 'icons.dart';
 import 'message_list.dart';
+import 'page.dart';
 import 'store.dart';
 import 'text.dart';
 import 'theme.dart';


Test patch:
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index f1961fc809..dbcdf5ff1d 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -129,6 +129,9 @@ void main() {
     return findScrollView(tester).controller;
   }
 
+  final contentInputFinder = find.byWidgetPredicate(
+    (widget) => widget is TextField && widget.controller is ComposeContentController);
+
   group('MessageListPage', () {
     testWidgets('ancestorOf finds page state from message', (tester) async {
       await setupMessageListPage(tester,
@@ -346,6 +349,36 @@ void main() {
     });
   });
 
+  group('no-messages placeholder', () {
+    final findPlaceholder = find.byType(PageBodyEmptyContentPlaceholder);
+
+    testWidgets('Combined feed', (tester) async {
+      await setupMessageListPage(tester, narrow: CombinedFeedNarrow(), messages: []);
+      check(
+        find.descendant(
+          of: findPlaceholder,
+          matching: find.textContaining('There are no messages here.')),
+      ).findsOne();
+    });
+
+    testWidgets('when `messages` empty but `outboxMessages` not empty, show outboxes, not placeholder', (tester) async {
+      final channel = eg.stream();
+      await setupMessageListPage(tester,
+        narrow: TopicNarrow(channel.streamId, eg.t('topic')),
+        streams: [channel],
+        messages: []);
+      check(findPlaceholder).findsOne();
+
+      connection.prepare(json: SendMessageResult(id: 1).toJson());
+      await tester.enterText(contentInputFinder, 'asdfjkl;');
+      await tester.tap(find.byIcon(ZulipIcons.send));
+      await tester.pump(kLocalEchoDebounceDuration);
+
+      check(findPlaceholder).findsNothing();
+      check(find.text('asdfjkl;')).findsOne();
+    });
+  });
+
   group('presents message content appropriately', () {
     testWidgets('content not asked to consume insets (including bottom), even without compose box', (tester) async {
       // Regression test for: https://github.com/zulip/zulip-flutter/issues/736
@@ -1837,9 +1870,6 @@ void main() {
     final topicNarrow = eg.topicNarrow(stream.streamId, topic);
     const content = 'outbox message content';
 
-    final contentInputFinder = find.byWidgetPredicate(
-      (widget) => widget is TextField && widget.controller is ComposeContentController);
-
     Finder outboxMessageFinder = find.widgetWithText(
       OutboxMessageWithPossibleSender, content, skipOffstage: true);