diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index df2c4ab947..d6e19a44ca 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg
new file mode 100644
index 0000000000..880b02b674
--- /dev/null
+++ b/assets/icons/chevron_down.svg
@@ -0,0 +1,3 @@
+<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7071 0.292893C13.3166 -0.0976309 12.6834 -0.0976309 12.2929 0.292893L7 5.58579L1.70711 0.292893C1.31658 -0.0976311 0.683418 -0.0976311 0.292894 0.292893C-0.0976312 0.683417 -0.0976312 1.31658 0.292894 1.70711L6.29289 7.70711C6.68342 8.09763 7.31658 8.09763 7.70711 7.70711L13.7071 1.70711C14.0976 1.31658 14.0976 0.683417 13.7071 0.292893Z" fill="black"/>
+</svg>
diff --git a/assets/icons/list.svg b/assets/icons/list.svg
new file mode 100644
index 0000000000..c07afa80b3
--- /dev/null
+++ b/assets/icons/list.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
+  <path fill="#000" d="M2.998 6c0-.55.446-.997.997-.997h11a.998.998 0 0 1 0 1.994h-11A.997.997 0 0 1 2.997 6Zm0 6c0-.55.446-.998.997-.998h9a.998.998 0 0 1 0 1.995h-9A.998.998 0 0 1 2.997 12Zm.997 5.003h11a.998.998 0 0 1 0 1.994h-11a.998.998 0 0 1 0-1.994Zm16-12h.01a.998.998 0 0 1 0 1.994h-.01a.997.997 0 1 1 0-1.994Zm0 5.999h.01a.998.998 0 0 1 0 1.995h-.01a.998.998 0 0 1 0-1.995Zm0 6.001h.01a.998.998 0 0 1 0 1.994h-.01a.998.998 0 0 1 0-1.994Z"/>
+</svg>
\ No newline at end of file
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index ea2e10cff3..321a3984c3 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -108,6 +108,10 @@
   "@actionSheetOptionUnresolveTopic": {
     "description": "Label for the 'Mark as unresolved' button on the topic action sheet."
   },
+  "actionSheetOptionTopicList": "Topic list",
+  "@actionSheetOptionTopicList": {
+    "description": "Label for a button in the channel action sheet that opens the list of topics in the channel"
+  },
   "errorResolveTopicFailedTitle": "Failed to mark topic as resolved",
   "@errorResolveTopicFailedTitle": {
     "description": "Error title when marking a topic as resolved failed."
@@ -710,6 +714,10 @@
   "@channelFeedButtonTooltip": {
     "description": "Tooltip for button to navigate to a given channel's feed"
   },
+  "topicListButtonTooltip": "Topic list",
+  "@topicListButtonTooltip": {
+    "description": "Tooltip for button to navigate to topic list page."
+  },
   "notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}",
   "@notifGroupDmConversationLabel": {
     "description": "Label for a group DM conversation notification.",
@@ -868,6 +876,14 @@
   "@emojiPickerSearchEmoji": {
     "description": "Hint text for the emoji picker search text field."
   },
+  "errorFetchingTopics": "Error fetching topics",
+  "@errorFetchingTopics": {
+    "description": "Error title when fetching the topics failed."
+  },
+  "noTopicsInChannel": "No topics in the channel",
+  "@noTopicsInChannel": {
+    "description": "Text to show when a channel has no topics."
+  },
   "noEarlierMessages": "No earlier messages",
   "@noEarlierMessages": {
     "description": "Text to show at the start of a message list if there are no earlier messages."
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index d09393e774..ab306a7ecc 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -267,6 +267,12 @@ abstract class ZulipLocalizations {
   /// **'Mark as unresolved'**
   String get actionSheetOptionUnresolveTopic;
 
+  /// Label for a button in the channel action sheet that opens the list of topics in the channel
+  ///
+  /// In en, this message translates to:
+  /// **'Topic list'**
+  String get actionSheetOptionTopicList;
+
   /// Error title when marking a topic as resolved failed.
   ///
   /// In en, this message translates to:
@@ -1047,6 +1053,12 @@ abstract class ZulipLocalizations {
   /// **'Channel feed'**
   String get channelFeedButtonTooltip;
 
+  /// Tooltip for button to navigate to topic list page.
+  ///
+  /// In en, this message translates to:
+  /// **'Topic list'**
+  String get topicListButtonTooltip;
+
   /// Label for a group DM conversation notification.
   ///
   /// In en, this message translates to:
@@ -1263,6 +1275,18 @@ abstract class ZulipLocalizations {
   /// **'Search emoji'**
   String get emojiPickerSearchEmoji;
 
+  /// Error title when fetching the topics failed.
+  ///
+  /// In en, this message translates to:
+  /// **'Error fetching topics'**
+  String get errorFetchingTopics;
+
+  /// Text to show when a channel has no topics.
+  ///
+  /// In en, this message translates to:
+  /// **'No topics in the channel'**
+  String get noTopicsInChannel;
+
   /// Text to show at the start of a message list if there are no earlier messages.
   ///
   /// 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 c2478f4613..0159b8351d 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Mark as unresolved';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Channel feed';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Search emoji';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'No earlier messages';
 
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 289ba33af2..1be876f1b8 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Mark as unresolved';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Channel feed';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Search emoji';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'No earlier messages';
 
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 00537f73a2..efd2bee1aa 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Mark as unresolved';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Channel feed';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Search emoji';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'No earlier messages';
 
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 3c063e91da..15cdca1dbb 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Mark as unresolved';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Channel feed';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Search emoji';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'No earlier messages';
 
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 64705fbe02..035b5bfbef 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Oznacz brak rozwiązania';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Nie udało się oznaczyć jako rozwiązany';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Strumień kanału';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Szukaj emoji';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'Brak historii';
 
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 911fc281b2..fa00e183b0 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Mark as unresolved';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Лента канала';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Поиск эмодзи';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'No earlier messages';
 
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 0cb42c3a37..5371c69e12 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -91,6 +91,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get actionSheetOptionUnresolveTopic => 'Mark as unresolved';
 
+  @override
+  String get actionSheetOptionTopicList => 'Topic list';
+
   @override
   String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved';
 
@@ -553,6 +556,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get channelFeedButtonTooltip => 'Channel feed';
 
+  @override
+  String get topicListButtonTooltip => 'Topic list';
+
   @override
   String notifGroupDmConversationLabel(String senderFullName, int numOthers) {
     String _temp0 = intl.Intl.pluralLogic(
@@ -675,6 +681,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get emojiPickerSearchEmoji => 'Hľadať emotikon';
 
+  @override
+  String get errorFetchingTopics => 'Error fetching topics';
+
+  @override
+  String get noTopicsInChannel => 'No topics in the channel';
+
   @override
   String get noEarlierMessages => 'No earlier messages';
 
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 0594bd27d2..69117e2ef0 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -28,6 +28,7 @@ import 'page.dart';
 import 'store.dart';
 import 'text.dart';
 import 'theme.dart';
+import 'topic_list.dart';
 
 void _showActionSheet(
   BuildContext context, {
@@ -175,23 +176,46 @@ void showChannelActionSheet(BuildContext context, {
   final store = PerAccountStoreWidget.of(pageContext);
 
   final optionButtons = <ActionSheetMenuItemButton>[];
+
+  optionButtons.add(
+    TopicListButton(pageContext: pageContext, channelId: channelId));
+
   final unreadCount = store.unreads.countInChannelNarrow(channelId);
   if (unreadCount > 0) {
     optionButtons.add(
       MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId));
   }
-  if (optionButtons.isEmpty) {
-    // TODO(a11y): This case makes a no-op gesture handler; as a consequence,
-    //   we're presenting some UI (to people who use screen-reader software) as
-    //   though it offers a gesture interaction that it doesn't meaningfully
-    //   offer, which is confusing. The solution here is probably to remove this
-    //   is-empty case by having at least one button that's always present,
-    //   such as "copy link to channel".
-    return;
-  }
+
   _showActionSheet(pageContext, optionButtons: optionButtons);
 }
 
+class TopicListButton extends ActionSheetMenuItemButton {
+  const TopicListButton({
+    super.key,
+    required this.channelId,
+    required super.pageContext,
+  });
+
+  final int channelId;
+
+  @override
+  IconData get icon => ZulipIcons.list;
+
+  @override
+  String label(ZulipLocalizations zulipLocalizations) {
+    return zulipLocalizations.actionSheetOptionTopicList;
+  }
+
+  @override
+  void onPressed() {
+    Navigator.push(pageContext,
+      TopicListPage.buildRoute(
+        context: pageContext,
+        streamId: channelId,
+      ));
+  }
+}
+
 class MarkChannelAsReadButton extends ActionSheetMenuItemButton {
   const MarkChannelAsReadButton({
     super.key,
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index ff9b2f7794..66026870d8 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -51,101 +51,107 @@ abstract final class ZulipIcons {
   /// The Zulip custom icon "check_remove".
   static const IconData check_remove = IconData(0xf109, fontFamily: "Zulip Icons");
 
+  /// The Zulip custom icon "chevron_down".
+  static const IconData chevron_down = IconData(0xf10a, fontFamily: "Zulip Icons");
+
   /// The Zulip custom icon "chevron_right".
-  static const IconData chevron_right = IconData(0xf10a, fontFamily: "Zulip Icons");
+  static const IconData chevron_right = IconData(0xf10b, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "clock".
-  static const IconData clock = IconData(0xf10b, fontFamily: "Zulip Icons");
+  static const IconData clock = IconData(0xf10c, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "contacts".
-  static const IconData contacts = IconData(0xf10c, fontFamily: "Zulip Icons");
+  static const IconData contacts = IconData(0xf10d, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "copy".
-  static const IconData copy = IconData(0xf10d, fontFamily: "Zulip Icons");
+  static const IconData copy = IconData(0xf10e, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "follow".
-  static const IconData follow = IconData(0xf10e, fontFamily: "Zulip Icons");
+  static const IconData follow = IconData(0xf10f, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "format_quote".
-  static const IconData format_quote = IconData(0xf10f, fontFamily: "Zulip Icons");
+  static const IconData format_quote = IconData(0xf110, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "globe".
-  static const IconData globe = IconData(0xf110, fontFamily: "Zulip Icons");
+  static const IconData globe = IconData(0xf111, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "group_dm".
-  static const IconData group_dm = IconData(0xf111, fontFamily: "Zulip Icons");
+  static const IconData group_dm = IconData(0xf112, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "hash_italic".
-  static const IconData hash_italic = IconData(0xf112, fontFamily: "Zulip Icons");
+  static const IconData hash_italic = IconData(0xf113, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "hash_sign".
-  static const IconData hash_sign = IconData(0xf113, fontFamily: "Zulip Icons");
+  static const IconData hash_sign = IconData(0xf114, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "image".
-  static const IconData image = IconData(0xf114, fontFamily: "Zulip Icons");
+  static const IconData image = IconData(0xf115, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "inbox".
-  static const IconData inbox = IconData(0xf115, fontFamily: "Zulip Icons");
+  static const IconData inbox = IconData(0xf116, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "info".
-  static const IconData info = IconData(0xf116, fontFamily: "Zulip Icons");
+  static const IconData info = IconData(0xf117, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "inherit".
-  static const IconData inherit = IconData(0xf117, fontFamily: "Zulip Icons");
+  static const IconData inherit = IconData(0xf118, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "language".
-  static const IconData language = IconData(0xf118, fontFamily: "Zulip Icons");
+  static const IconData language = IconData(0xf119, fontFamily: "Zulip Icons");
+
+  /// The Zulip custom icon "list".
+  static const IconData list = IconData(0xf11a, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "lock".
-  static const IconData lock = IconData(0xf119, fontFamily: "Zulip Icons");
+  static const IconData lock = IconData(0xf11b, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "menu".
-  static const IconData menu = IconData(0xf11a, fontFamily: "Zulip Icons");
+  static const IconData menu = IconData(0xf11c, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "message_checked".
-  static const IconData message_checked = IconData(0xf11b, fontFamily: "Zulip Icons");
+  static const IconData message_checked = IconData(0xf11d, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "message_feed".
-  static const IconData message_feed = IconData(0xf11c, fontFamily: "Zulip Icons");
+  static const IconData message_feed = IconData(0xf11e, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "mute".
-  static const IconData mute = IconData(0xf11d, fontFamily: "Zulip Icons");
+  static const IconData mute = IconData(0xf11f, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "read_receipts".
-  static const IconData read_receipts = IconData(0xf11e, fontFamily: "Zulip Icons");
+  static const IconData read_receipts = IconData(0xf120, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "send".
-  static const IconData send = IconData(0xf11f, fontFamily: "Zulip Icons");
+  static const IconData send = IconData(0xf121, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "settings".
-  static const IconData settings = IconData(0xf120, fontFamily: "Zulip Icons");
+  static const IconData settings = IconData(0xf122, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "share".
-  static const IconData share = IconData(0xf121, fontFamily: "Zulip Icons");
+  static const IconData share = IconData(0xf123, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "share_ios".
-  static const IconData share_ios = IconData(0xf122, fontFamily: "Zulip Icons");
+  static const IconData share_ios = IconData(0xf124, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "smile".
-  static const IconData smile = IconData(0xf123, fontFamily: "Zulip Icons");
+  static const IconData smile = IconData(0xf125, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "star".
-  static const IconData star = IconData(0xf124, fontFamily: "Zulip Icons");
+  static const IconData star = IconData(0xf126, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "star_filled".
-  static const IconData star_filled = IconData(0xf125, fontFamily: "Zulip Icons");
+  static const IconData star_filled = IconData(0xf127, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "three_person".
-  static const IconData three_person = IconData(0xf126, fontFamily: "Zulip Icons");
+  static const IconData three_person = IconData(0xf128, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "topic".
-  static const IconData topic = IconData(0xf127, fontFamily: "Zulip Icons");
+  static const IconData topic = IconData(0xf129, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "unmute".
-  static const IconData unmute = IconData(0xf128, fontFamily: "Zulip Icons");
+  static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons");
 
   /// The Zulip custom icon "user".
-  static const IconData user = IconData(0xf129, fontFamily: "Zulip Icons");
+  static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons");
 
   // END GENERATED ICON DATA
 }
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 1e95a1be49..9546d6e7dd 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -27,6 +27,7 @@ import 'sticky_header.dart';
 import 'store.dart';
 import 'text.dart';
 import 'theme.dart';
+import 'topic_list.dart';
 
 /// Message-list styles that differ between light and dark themes.
 class MessageListTheme extends ThemeExtension<MessageListTheme> {
@@ -252,6 +253,29 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
             narrow: ChannelNarrow(streamId)))));
     }
 
+    if (narrow case ChannelNarrow(:final streamId)) {
+      final designVariables = DesignVariables.of(context);
+      (actions ??= []).add(TextButton(
+        child: Text('TOPICS',
+          style: TextStyle(
+            color: designVariables.icon,
+            fontWeight: FontWeight.w600,
+            fontSize: 18,
+            height: 19/18,
+            letterSpacing: 0,
+            textBaseline: TextBaseline.alphabetic,
+            leadingDistribution: TextLeadingDistribution.even,
+          ),
+        ),
+        onPressed: () =>
+          Navigator.push(context,
+            TopicListPage.buildRoute(
+              context: context,
+              streamId: streamId,
+            ))
+      ));
+    }
+
     // Insert a PageRoot here, to provide a context that can be used for
     // MessageListPage.ancestorOf.
     return PageRoot(child: Scaffold(
diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart
index eea9677045..2a56c1131e 100644
--- a/lib/widgets/theme.dart
+++ b/lib/widgets/theme.dart
@@ -135,6 +135,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
     bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15),
     bgMenuButtonActive: Colors.black.withValues(alpha: 0.05),
     bgMenuButtonSelected: Colors.white,
+    bgMessageRegular: const Color(0xffffffff),
     bgTopBar: const Color(0xfff5f5f5),
     borderBar: Colors.black.withValues(alpha: 0.2),
     borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2),
@@ -192,6 +193,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
     bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37),
     bgMenuButtonActive: Colors.black.withValues(alpha: 0.2),
     bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25),
+    bgMessageRegular: const Color(0xff1d1d1d),
     bgTopBar: const Color(0xff242424),
     borderBar: const Color(0xffffffff).withValues(alpha: 0.1),
     borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1),
@@ -257,6 +259,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
     required this.bgCounterUnread,
     required this.bgMenuButtonActive,
     required this.bgMenuButtonSelected,
+    required this.bgMessageRegular,
     required this.bgTopBar,
     required this.borderBar,
     required this.borderMenuButtonSelected,
@@ -323,6 +326,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
   final Color bgCounterUnread;
   final Color bgMenuButtonActive;
   final Color bgMenuButtonSelected;
+  final Color bgMessageRegular;
   final Color bgTopBar;
   final Color borderBar;
   final Color borderMenuButtonSelected;
@@ -384,6 +388,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
     Color? bgCounterUnread,
     Color? bgMenuButtonActive,
     Color? bgMenuButtonSelected,
+    Color? bgMessageRegular,
     Color? bgTopBar,
     Color? borderBar,
     Color? borderMenuButtonSelected,
@@ -440,6 +445,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
       bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread,
       bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive,
       bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected,
+      bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular,
       bgTopBar: bgTopBar ?? this.bgTopBar,
       borderBar: borderBar ?? this.borderBar,
       borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected,
@@ -503,6 +509,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
       bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!,
       bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!,
       bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!,
+      bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!,
       bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!,
       borderBar: Color.lerp(borderBar, other.borderBar, t)!,
       borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!,
diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart
new file mode 100644
index 0000000000..59c4c87177
--- /dev/null
+++ b/lib/widgets/topic_list.dart
@@ -0,0 +1,326 @@
+import 'package:flutter/material.dart';
+
+import '../api/route/channels.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import '../model/narrow.dart';
+import '../model/unreads.dart';
+import 'action_sheet.dart';
+import 'app_bar.dart';
+import 'channel_colors.dart';
+import 'dialog.dart';
+import 'icons.dart';
+import 'message_list.dart';
+import 'page.dart';
+import 'store.dart';
+import 'text.dart';
+import 'theme.dart';
+import 'unread_count_badge.dart';
+
+class TopicListPage extends StatelessWidget {
+  const TopicListPage({super.key, required this.streamId});
+
+  static AccountRoute<void> buildRoute({int? accountId, BuildContext? context,
+      required int streamId}) {
+    return MaterialAccountWidgetRoute(accountId: accountId, context: context,
+      page: TopicListPage(streamId: streamId));
+  }
+
+  final int streamId;
+
+  @override
+  Widget build(BuildContext context) {
+    final designVariables = DesignVariables.of(context);
+
+    return PageRoot(child: Scaffold(
+      appBar: ZulipAppBar(
+        buildTitle: (willCenterTitle) => _TopicListAppBarTitle(
+          streamId: streamId,
+          willCenterTitle: willCenterTitle
+        ),
+        backgroundColor: () {
+          final store = PerAccountStoreWidget.of(context);
+          final subscription = store.subscriptions[streamId];
+          if (subscription == null) return Colors.transparent;
+
+          return Theme.of(context).brightness == Brightness.light
+              ? ChannelColorSwatches.light.forBaseColor(subscription.color).barBackground
+              : ChannelColorSwatches.dark.forBaseColor(subscription.color).barBackground;
+        }(),
+        shape: Border(bottom: BorderSide(
+          color: designVariables.borderBar,
+          width: 1,
+        )),
+        actions: [
+          IconButton(
+            icon: const Icon(ZulipIcons.message_feed),
+            onPressed: () {
+              Navigator.push(context, MessageListPage.buildRoute(
+                context: context,
+                narrow: ChannelNarrow(streamId),
+              ));
+            },
+          ),
+        ],
+      ),
+      body: TopicListPageBody(streamId: streamId),
+    ));
+  }
+}
+
+class _TopicListAppBarTitle extends StatelessWidget {
+  const _TopicListAppBarTitle({
+    required this.streamId,
+    required this.willCenterTitle,
+  });
+
+  final int streamId;
+  final bool willCenterTitle;
+
+  @override
+  Widget build(BuildContext context) {
+    final store = PerAccountStoreWidget.of(context);
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    final stream = store.streams[streamId];
+    final subscription = store.subscriptions[streamId];
+    final designVariables = DesignVariables.of(context);
+
+    final iconData = subscription != null ? iconDataForStream(subscription) : null;
+    final alignment = willCenterTitle
+      ? Alignment.center
+      : AlignmentDirectional.centerStart;
+
+    return SizedBox(
+      width: double.infinity,
+      child: Align(alignment: alignment,
+        child: Row(
+          mainAxisSize: MainAxisSize.min,
+          crossAxisAlignment: CrossAxisAlignment.center,
+          children: [
+            Icon(size: 16, iconData),
+            const SizedBox(width: 4),
+            Flexible(child: Row(
+              children: [
+                Text(
+                  stream?.name ?? zulipLocalizations.unknownChannelName,
+                  style: recipientHeaderTextStyle(context),
+                ),
+                const SizedBox(width: 10),
+                Icon(ZulipIcons.chevron_down, size: 10, color: designVariables.icon),
+              ],
+            )),
+          ]))
+    );
+  }
+}
+
+class TopicListPageBody extends StatefulWidget {
+  const TopicListPageBody({super.key, required this.streamId});
+
+  final int streamId;
+
+  @override
+  State<TopicListPageBody> createState() => _TopicListPageBodyState();
+}
+
+class _TopicListPageBodyState extends State<TopicListPageBody>
+    with PerAccountStoreAwareStateMixin<TopicListPageBody> {
+  bool _isLoading = true;
+  List<GetStreamTopicsEntry>? _topics;
+  Unreads? _unreadsModel;
+
+  @override
+  void onNewStore() {
+    _unreadsModel?.removeListener(_modelChanged);
+    _unreadsModel = PerAccountStoreWidget.of(context).unreads
+      ..addListener(_modelChanged);
+    _fetchTopics();
+  }
+
+  Future<void> _fetchTopics() async {
+    setState(() {
+      _isLoading = true;
+    });
+
+    try {
+      final store = PerAccountStoreWidget.of(context);
+      final response = await getStreamTopics(store.connection, streamId: widget.streamId);
+
+      if (!mounted) return;
+
+      final sortedTopics = response.topics;
+      sortedTopics.sort((a, b) => b.maxId.compareTo(a.maxId));
+
+      setState(() {
+        _topics = sortedTopics;
+        _isLoading = false;
+      });
+    } catch (e) {
+      if (!mounted) return;
+
+      setState(() {
+        _isLoading = false;
+      });
+
+      final zulipLocalizations = ZulipLocalizations.of(context);
+      showErrorDialog(
+        context: context,
+        title: zulipLocalizations.errorFetchingTopics,
+        message: e.toString());
+    }
+  }
+
+  void _modelChanged() {
+    setState(() {
+      // The actual state lives in the models.
+      // This method was called because they just changed.
+    });
+  }
+
+  @override
+  void dispose() {
+    _unreadsModel?.removeListener(_modelChanged);
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_isLoading) {
+      return const Center(child: CircularProgressIndicator());
+    }
+
+    if (_topics == null || _topics!.isEmpty) {
+      return Center(
+        child: Text(ZulipLocalizations.of(context).noTopicsInChannel,
+          style: TextStyle(
+            color: DesignVariables.of(context).labelMenuButton,
+            fontSize: 16,
+          )),
+      );
+    }
+
+    return SafeArea(
+      child: ListView.builder(
+        itemCount: _topics!.length,
+        itemBuilder: (context, index) {
+          final topic = _topics![index];
+          return TopicListItem(
+            streamId: widget.streamId,
+            topic: topic,
+          );
+        },
+      ),
+    );
+  }
+}
+
+class TopicListItem extends StatelessWidget {
+  const TopicListItem({
+    super.key,
+    required this.streamId,
+    required this.topic,
+  });
+
+  final int streamId;
+  final GetStreamTopicsEntry topic;
+
+  @override
+  Widget build(BuildContext context) {
+    final store = PerAccountStoreWidget.of(context);
+    final designVariables = DesignVariables.of(context);
+
+    final unreads = store.unreads.countInNarrow(
+      TopicNarrow(streamId, topic.name)
+    );
+    final hasUnreads = unreads > 0;
+
+    bool hasMention = false;
+    if (hasUnreads) {
+      final unreadMessageIds = store.unreads.streams[streamId]?[topic.name] ?? [];
+      hasMention = unreadMessageIds.any(
+        (messageId) => store.unreads.mentions.contains(messageId));
+    }
+
+    final visibilityIcon = iconDataForTopicVisibilityPolicy(
+      store.topicVisibilityPolicy(streamId, topic.name));
+
+    return Material(
+      color: designVariables.bgMessageRegular,
+      child: InkWell(
+        onTap: () {
+          Navigator.push(context, MessageListPage.buildRoute(
+            context: context,
+            narrow: TopicNarrow(streamId, topic.name),
+          ));
+        },
+        onLongPress: () {
+          showTopicActionSheet(
+            context,
+            channelId: streamId,
+            topic: topic.name,
+            someMessageIdInTopic: topic.maxId,
+          );
+        },
+        child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
+          const SizedBox(width: 28),
+          Padding(
+            padding: const EdgeInsets.symmetric(vertical: 10.0),
+            child: SizedBox(
+              height: 16,
+              width: 16,
+              child: topic.name.isResolved
+                ? Opacity(
+                  opacity: 0.4,
+                  child: Icon(
+                      ZulipIcons.check,
+                      size: 16,
+                      color: designVariables.textMessage,
+                    ),
+                )
+                : null,
+            ),
+          ),
+          Expanded(
+            child: Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+              child: Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Expanded(
+                    child: Text(topic.name.unresolve().toString(),
+                      style: TextStyle(
+                        fontSize: 17,
+                        height: (20 / 17),
+                        color: designVariables.textMessage,
+                      ).merge(weightVariableTextStyle(context, wght: 400)),
+                    ),
+                  ),
+                  const SizedBox(width: 8),
+                  if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign),
+                  if (visibilityIcon != null) _IconMarker(icon: visibilityIcon),
+                  if (hasUnreads) Padding(
+                    padding: const EdgeInsetsDirectional.only(end: 4),
+                    child: UnreadCountBadge(count: unreads,
+                      backgroundColor: designVariables.bgCounterUnread, bold: true),
+                  ),
+                  const SizedBox(width: 8),
+                ]))),
+        ])));
+  }
+}
+
+class _IconMarker extends StatelessWidget {
+  const _IconMarker({required this.icon});
+
+  final IconData icon;
+
+  @override
+  Widget build(BuildContext context) {
+    final designVariables = DesignVariables.of(context);
+    return Padding(
+      padding: const EdgeInsetsDirectional.only(end: 4),
+      child: Opacity(opacity: 0.4,
+        child: Icon(icon, size: 16, color: designVariables.textMessage),
+      ),
+    );
+  }
+}
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 8aeeec4eed..806acaa4c8 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -31,7 +31,9 @@ import 'package:zulip/widgets/icons.dart';
 import 'package:zulip/widgets/inbox.dart';
 import 'package:zulip/widgets/message_list.dart';
 import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
+import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/subscription_list.dart';
+import 'package:zulip/widgets/topic_list.dart';
 import '../api/fake_api.dart';
 
 import '../example_data.dart' as eg;
@@ -40,9 +42,11 @@ import '../model/binding.dart';
 import '../model/test_store.dart';
 import '../stdlib_checks.dart';
 import '../test_clipboard.dart';
+import '../test_navigation.dart';
 import '../test_share_plus.dart';
 import 'compose_box_checks.dart';
 import 'dialog_checks.dart';
+import 'page_checks.dart';
 import 'test_app.dart';
 
 late PerAccountStore store;
@@ -154,6 +158,7 @@ void main() {
       ZulipStream? channel,
       List<StreamMessage>? messages,
       required Narrow narrow,
+      List<NavigatorObserver>? navObservers,
     }) async {
       channel ??= someChannel;
       messages ??= [someMessage];
@@ -162,6 +167,7 @@ void main() {
         foundOldest: true, messages: messages).toJson());
       await tester.pumpWidget(TestZulipApp(
         accountId: eg.selfAccount.id,
+        navigatorObservers: navObservers ?? [],
         child: MessageListPage(
           initNarrow: narrow)));
       await tester.pumpAndSettle();
@@ -201,6 +207,7 @@ void main() {
       void checkButtons() {
         check(actionSheetFinder).findsOne();
         checkButton('Mark channel as read');
+        checkButton('Topic list');
       }
 
       testWidgets('show from inbox', (tester) async {
@@ -218,7 +225,7 @@ void main() {
       testWidgets('show with no unread messages', (tester) async {
         await prepare(hasUnreadMessages: false);
         await showFromSubscriptionList(tester);
-        check(actionSheetFinder).findsNothing();
+        check(findButtonForLabel('Mark channel as read')).findsNothing();
       });
 
       testWidgets('show from app bar in channel narrow', (tester) async {
@@ -287,6 +294,31 @@ void main() {
           expectedTitle: "Mark as read failed");
       });
     });
+
+    group('TopicListButton', () {
+      testWidgets('navigates to topic list page', (tester) async {
+        final pushedRoutes = <Route<void>>[];
+        final navObserver = TestNavigatorObserver()
+          ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+
+        await prepare();
+        await showFromAppBar(tester,
+          narrow: ChannelNarrow(someChannel.streamId),
+          navObservers: [navObserver]);
+
+        pushedRoutes.clear();
+        await tester.tap(findButtonForLabel('Topic list'));
+        await tester.pumpAndSettle();
+
+        final topicListRoute = pushedRoutes
+          .whereType<MaterialAccountWidgetRoute<void>>()
+          .single;
+        check(topicListRoute)
+          .page.isA<TopicListPage>()
+          .has((page) => page.streamId, 'streamId')
+          .equals(someChannel.streamId);
+      });
+    });
   });
 
   group('topic action sheet', () {
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index 53ad1334ef..8f31fefb40 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -26,6 +26,7 @@ import 'package:zulip/widgets/message_list.dart';
 import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/store.dart';
 import 'package:zulip/widgets/channel_colors.dart';
+import 'package:zulip/widgets/topic_list.dart';
 
 import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
@@ -226,6 +227,32 @@ void main() {
           .equals(ChannelNarrow(channel.streamId));
     });
 
+    testWidgets('has topic list button for topic list page', (tester) async {
+      final pushedRoutes = <Route<void>>[];
+      final navObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      final channel = eg.stream();
+      final message = eg.streamMessage(stream: channel);
+      await setupMessageListPage(tester, narrow: ChannelNarrow(channel.streamId),
+        navObservers: [navObserver],
+        streams: [channel],
+        messages: [message]);
+
+      assert(pushedRoutes.length == 1);
+      pushedRoutes.clear();
+
+      await tester.tap(find.text('TOPICS'));
+      await tester.pumpAndSettle();
+
+      final topicListRoute = pushedRoutes
+        .whereType<MaterialAccountWidgetRoute<void>>()
+        .single;
+      check(topicListRoute)
+        .page.isA<TopicListPage>()
+        .has((page) => page.streamId, 'streamId')
+        .equals(channel.streamId);
+    });
+
     testWidgets('show topic visibility policy for topic narrows', (tester) async {
       final channel = eg.stream();
       const topic = 'topic';
diff --git a/test/widgets/topic_list_test.dart b/test/widgets/topic_list_test.dart
new file mode 100644
index 0000000000..8581ea1b07
--- /dev/null
+++ b/test/widgets/topic_list_test.dart
@@ -0,0 +1,375 @@
+import 'package:checks/checks.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_checks/flutter_checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/initial_snapshot.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/route/channels.dart';
+import 'package:zulip/model/narrow.dart';
+import 'package:zulip/widgets/icons.dart';
+import 'package:zulip/widgets/message_list.dart';
+import 'package:zulip/widgets/page.dart';
+import 'package:zulip/widgets/topic_list.dart';
+import 'package:zulip/widgets/unread_count_badge.dart';
+
+import '../api/fake_api.dart';
+import '../model/binding.dart';
+import '../example_data.dart' as eg;
+import '../model/test_store.dart';
+import '../test_navigation.dart';
+import 'message_list_checks.dart';
+import 'page_checks.dart';
+import 'test_app.dart';
+
+void main() {
+  TestZulipBinding.ensureInitialized();
+
+  Widget? findRowByLabel(WidgetTester tester, String label) {
+    final textWidgets = tester.widgetList<Text>(find.text(label));
+    if (textWidgets.isEmpty) return null;
+
+    final rows = tester.widgetList<Row>(
+      find.descendant(
+        of: find.byType(TopicListItem),
+        matching: find.byType(Row),
+      ));
+
+    for (final row in rows) {
+      if (tester.widgetList(find.descendant(
+        of: find.byWidget(row),
+        matching: find.text(label),
+      )).isNotEmpty) {
+        return row;
+      }
+    }
+    return null;
+  }
+
+  Future<void> setupTopicListPage(WidgetTester tester, {
+    required List<GetStreamTopicsEntry> topics,
+    ZulipStream? stream,
+    Subscription? subscription,
+    UnreadMessagesSnapshot? unreadMsgs,
+    List<NavigatorObserver> navObservers = const [],
+  }) async {
+    addTearDown(testBinding.reset);
+    final effectiveStream = stream ?? eg.stream();
+    final initialSnapshot = eg.initialSnapshot(
+      streams: [effectiveStream],
+      subscriptions: subscription != null ? [subscription] : null,
+      unreadMsgs: unreadMsgs,
+    );
+    await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
+    final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+    final connection = store.connection as FakeApiConnection;
+    connection.prepare(json: GetStreamTopicsResult(topics: topics).toJson());
+
+    await tester.pumpWidget(TestZulipApp(
+      accountId: eg.selfAccount.id,
+      navigatorObservers: navObservers,
+      child: TopicListPage(streamId: effectiveStream.streamId)));
+  }
+
+  group('TopicListPage', () {
+    testWidgets('shows loading indicator initially', (tester) async {
+      await setupTopicListPage(tester, topics: []);
+      check(find.byType(CircularProgressIndicator)).findsOne();
+    });
+
+    testWidgets('shows empty state when no topics', (tester) async {
+      await setupTopicListPage(tester, topics: []);
+      await tester.pumpAndSettle();
+      check(find.text('No topics in the channel')).findsOne();
+    });
+
+    testWidgets('shows topics sorted by maxId', (tester) async {
+      final topics = [
+        eg.getStreamTopicsEntry(maxId: 1, name: 'Topic A'),
+        eg.getStreamTopicsEntry(maxId: 3, name: 'Topic B'),
+        eg.getStreamTopicsEntry(maxId: 2, name: 'Topic C'),
+      ];
+      await setupTopicListPage(tester, topics: topics);
+      await tester.pumpAndSettle();
+
+      final topicWidgets = tester.widgetList<Text>(find.byType(Text))
+        .where((widget) => ['Topic A', 'Topic B', 'Topic C']
+          .contains(widget.data)).toList();
+
+      check(topicWidgets.map((w) => w.data))
+        .deepEquals(['Topic B', 'Topic C', 'Topic A']);
+    });
+
+    testWidgets('navigates to message list on topic tap', (tester) async {
+      final pushedRoutes = <Route<void>>[];
+      final navObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      final stream = eg.stream();
+      final topic = eg.getStreamTopicsEntry(name: 'test topic');
+      final message = eg.streamMessage(stream: stream, topic: 'test topic');
+
+      await setupTopicListPage(tester,
+        stream: stream,
+        topics: [topic],
+        subscription: eg.subscription(stream),
+        navObservers: [navObserver]);
+
+      final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+      await store.addUser(eg.selfUser);
+
+      final connection = store.connection as FakeApiConnection;
+      connection.prepare(json:
+      eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
+
+      await tester.pumpAndSettle();
+      pushedRoutes.clear();
+
+      await tester.tap(find.text('test topic'));
+      await tester.pumpAndSettle();
+
+      check(pushedRoutes).single
+        .isA<MaterialAccountWidgetRoute>()
+        .page.isA<MessageListPage>()
+        .initNarrow.equals(TopicNarrow(stream.streamId, topic.name));
+    });
+
+    testWidgets('shows unread count badge', (tester) async {
+      final stream = eg.stream();
+      final topic = eg.getStreamTopicsEntry(name: 'test topic');
+
+      await setupTopicListPage(tester,
+        stream: stream,
+        topics: [topic],
+        unreadMsgs: eg.unreadMsgs(channels: [
+          eg.unreadChannelMsgs(
+            streamId: stream.streamId,
+            topic: 'test topic',
+            unreadMessageIds: [1, 2, 3]),
+        ]));
+      await tester.pumpAndSettle();
+
+      check(find.text('3')).findsOne();
+    });
+
+    testWidgets('shows channel name in app bar', (tester) async {
+      final stream = eg.stream(name: 'Test Stream');
+      await setupTopicListPage(tester,
+        stream: stream,
+        topics: [],
+        subscription: eg.subscription(stream));
+      await tester.pumpAndSettle();
+
+      check(find.text('Test Stream')).findsOne();
+    });
+
+    testWidgets('shows channel feed button in app bar', (tester) async {
+      final stream = eg.stream();
+
+      await setupTopicListPage(tester,
+        stream: stream,
+        topics: [],
+        subscription: eg.subscription(stream));
+      await tester.pumpAndSettle();
+
+      check(find.byIcon(ZulipIcons.message_feed)).findsOne();
+    });
+
+    testWidgets('navigates to channel narrow on channel feed button tap', (tester) async {
+      final pushedRoutes = <Route<void>>[];
+      final navObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      final stream = eg.stream();
+      final message = eg.streamMessage(stream: stream);
+
+      await setupTopicListPage(tester,
+        stream: stream,
+        topics: [],
+        subscription: eg.subscription(stream),
+        navObservers: [navObserver]);
+
+      final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+      await store.addUser(eg.selfUser);
+
+      final connection = store.connection as FakeApiConnection;
+      connection.prepare(json: eg.newestGetMessagesResult(
+        foundOldest: true,
+        messages: [message],
+      ).toJson());
+
+      await tester.pumpAndSettle();
+      pushedRoutes.clear();
+
+      await tester.tap(find.byIcon(ZulipIcons.message_feed));
+      await tester.pumpAndSettle();
+
+      check(pushedRoutes).single
+        .isA<MaterialAccountWidgetRoute>()
+        .page.isA<MessageListPage>()
+        .initNarrow.equals(ChannelNarrow(stream.streamId));
+    });
+
+    bool hasIcon(WidgetTester tester, {
+      required Widget? parent,
+      required IconData icon,
+    }) {
+      check(parent).isNotNull();
+      return tester.widgetList(find.descendant(
+        of: find.byWidget(parent!),
+        matching: find.byIcon(icon),
+      )).isNotEmpty;
+    }
+
+    group('mentions', () {
+      final stream = eg.stream();
+      final topic = eg.getStreamTopicsEntry(name: 'test topic');
+
+      testWidgets('topic with a mention', (tester) async {
+        final message = eg.streamMessage(
+          stream: stream,
+          topic: 'test topic',
+          flags: [MessageFlag.mentioned]);
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic],
+          subscription: eg.subscription(stream),
+          unreadMsgs: eg.unreadMsgs(
+            mentions: [message.id],
+            channels: [eg.unreadChannelMsgs(
+                streamId: stream.streamId,
+                topic: 'test topic',
+                unreadMessageIds: [message.id]),
+            ]));
+        await tester.pumpAndSettle();
+
+        check(hasIcon(tester,
+          parent: findRowByLabel(tester, topic.name.displayName),
+          icon: ZulipIcons.at_sign)).isTrue();
+      });
+
+      testWidgets('topic without a mention', (tester) async {
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic],
+          subscription: eg.subscription(stream),
+          unreadMsgs: eg.unreadMsgs(channels: [
+            eg.unreadChannelMsgs(
+              streamId: stream.streamId,
+              topic: 'test topic',
+              unreadMessageIds: [1]),
+          ]));
+        await tester.pumpAndSettle();
+
+        check(hasIcon(tester,
+          parent: findRowByLabel(tester, topic.name.displayName),
+          icon: ZulipIcons.at_sign)).isFalse();
+      });
+    });
+
+    group('topic visibility', () {
+      final stream = eg.stream();
+      final topic = eg.getStreamTopicsEntry(name: 'test topic');
+
+      testWidgets('followed', (tester) async {
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic],
+          subscription: eg.subscription(stream),
+          unreadMsgs: eg.unreadMsgs(channels: [
+            eg.unreadChannelMsgs(
+              streamId: stream.streamId,
+              topic: 'test topic',
+              unreadMessageIds: [1]),
+          ]));
+        await tester.pumpAndSettle();
+
+        final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+        await store.addUserTopic(stream, 'test topic', UserTopicVisibilityPolicy.followed);
+        await tester.pump();
+
+        check(hasIcon(tester,
+          parent: findRowByLabel(tester, topic.name.displayName),
+          icon: ZulipIcons.follow)).isTrue();
+      });
+
+      testWidgets('followed and mentioned', (tester) async {
+        final message = eg.streamMessage(
+          stream: stream,
+          topic: 'test topic',
+          flags: [MessageFlag.mentioned]);
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic],
+          subscription: eg.subscription(stream),
+          unreadMsgs: eg.unreadMsgs(
+            mentions: [message.id],
+            channels: [eg.unreadChannelMsgs(
+                streamId: stream.streamId,
+                topic: 'test topic',
+                unreadMessageIds: [message.id]),
+            ]));
+        await tester.pumpAndSettle();
+
+        final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+        await store.addUserTopic(stream, 'test topic', UserTopicVisibilityPolicy.followed);
+        await tester.pump();
+
+        check(hasIcon(tester,
+          parent: findRowByLabel(tester, topic.name.displayName),
+          icon: ZulipIcons.follow)).isTrue();
+        check(hasIcon(tester,
+          parent: findRowByLabel(tester, topic.name.displayName),
+          icon: ZulipIcons.at_sign)).isTrue();
+      });
+
+      testWidgets('unmuted', (tester) async {
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic],
+          subscription: eg.subscription(stream, isMuted: true),
+          unreadMsgs: eg.unreadMsgs(channels: [
+            eg.unreadChannelMsgs(
+              streamId: stream.streamId,
+              topic: 'test topic',
+              unreadMessageIds: [1]),
+          ]));
+        await tester.pumpAndSettle();
+
+        final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+        await store.addUserTopic(stream, 'test topic', UserTopicVisibilityPolicy.unmuted);
+        await tester.pump();
+
+        check(hasIcon(tester,
+          parent: findRowByLabel(tester, topic.name.displayName),
+          icon: ZulipIcons.unmute)).isTrue();
+      });
+    });
+
+    group('unread badge', () {
+      final stream = eg.stream();
+      final topic = eg.getStreamTopicsEntry(name: 'test topic');
+
+      testWidgets('shows unread count badge with correct count', (tester) async {
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic],
+          unreadMsgs: eg.unreadMsgs(channels: [
+            eg.unreadChannelMsgs(
+              streamId: stream.streamId,
+              topic: 'test topic',
+              unreadMessageIds: [1, 2, 3, 4, 5]),
+          ]));
+        await tester.pumpAndSettle();
+
+        check(find.text('5')).findsOne();
+      });
+
+      testWidgets('does not show unread badge when no unreads', (tester) async {
+        await setupTopicListPage(tester,
+          stream: stream,
+          topics: [topic]);
+        await tester.pumpAndSettle();
+
+        check(find.byType(UnreadCountBadge)).findsNothing();
+      });
+    });
+  });
+}