From 3974ff1c0ba4e4a3a31af8c556f943bafae0e4f0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 1 Jul 2025 11:18:56 -0700 Subject: [PATCH 1/3] page [nfc]: Move no-content placeholder widget to page.dart, from home.dart We can reuse this for the empty message list. --- lib/widgets/home.dart | 34 ----------------------- lib/widgets/inbox.dart | 2 +- lib/widgets/page.dart | 35 ++++++++++++++++++++++++ lib/widgets/recent_dm_conversations.dart | 2 +- lib/widgets/subscription_list.dart | 2 +- 5 files changed, 38 insertions(+), 37 deletions(-) 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 { } } -/// 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/page.dart b/lib/widgets/page.dart index a2c6fe52a1..d935e91d4d 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,36 @@ class LoadingPlaceholderPage extends StatelessWidget { ); } } + +/// 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)))); + } +} 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'; From 7bd509de8f4b83964dfb37a04d3eb0b8789e4dc6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 26 Jun 2025 20:17:21 -0700 Subject: [PATCH 2/3] msglist test [nfc]: Move a Finder helper outward for reuse --- test/widgets/message_list_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f1961fc809..0209d11275 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, @@ -1837,9 +1840,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); From 8749817f7fd0f040fd8f03a966b3ee744e734a03 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 1 Jul 2025 16:56:57 -0700 Subject: [PATCH 3/3] msglist: Friendlier placeholder text when narrow has no messages 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?) To support being used in the message-list page (in addition to Inbox, etc.), the placeholder widget only needs small changes, it turns out. --- assets/l10n/app_en.arb | 4 +++ lib/generated/l10n/zulip_localizations.dart | 6 ++++ .../l10n/zulip_localizations_ar.dart | 3 ++ .../l10n/zulip_localizations_de.dart | 3 ++ .../l10n/zulip_localizations_en.dart | 3 ++ .../l10n/zulip_localizations_it.dart | 3 ++ .../l10n/zulip_localizations_ja.dart | 3 ++ .../l10n/zulip_localizations_nb.dart | 3 ++ .../l10n/zulip_localizations_pl.dart | 3 ++ .../l10n/zulip_localizations_ru.dart | 3 ++ .../l10n/zulip_localizations_sk.dart | 3 ++ .../l10n/zulip_localizations_sl.dart | 3 ++ .../l10n/zulip_localizations_uk.dart | 3 ++ .../l10n/zulip_localizations_zh.dart | 3 ++ lib/widgets/message_list.dart | 7 +++++ lib/widgets/page.dart | 16 ++++++---- test/widgets/message_list_test.dart | 30 +++++++++++++++++++ 17 files changed, 93 insertions(+), 6 deletions(-) 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/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 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 d935e91d4d..35bdf34923 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -213,11 +213,15 @@ class LoadingPlaceholderPage extends StatelessWidget { } } -/// A "no content here" message, for the Inbox, Subscriptions, and DMs pages. +/// A "no content here" message for when a page has no content to show. /// -/// 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.) +/// 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}); @@ -228,9 +232,9 @@ class PageBodyEmptyContentPlaceholder extends StatelessWidget { final designVariables = DesignVariables.of(context); return SafeArea( - minimum: EdgeInsets.symmetric(horizontal: 24), + minimum: EdgeInsets.fromLTRB(24, 0, 24, 16), child: Padding( - padding: EdgeInsets.only(top: 48, bottom: 16), + padding: EdgeInsets.only(top: 48), child: Align( alignment: Alignment.topCenter, // TODO leading and trailing elements, like in Figma (given as SVGs): diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 0209d11275..dbcdf5ff1d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -349,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