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

Issue:
android: Notification grouping should treat topics case-insensitively
We use Android's "inbox style" notifications (this was #128 / PR #718) to group notifications by conversation.

For channel messages, this means grouping messages by topic. Topics in Zulip are meant to be case-insensitive, so we should ignore case for that grouping. For example, messages with the topic "move to Zulip" or the topic "move to zulip" should all be grouped together.[^1]

Other issues where we should be treating topics case-insensitively but we're not:

- #980
- #739

See our notification-display code for getting a message's "conversation key". It just takes the topic literally without normalizing it by case:

```dart
  static String _conversationKey(MessageFcmMessage data, String groupKey) {
    final conversation = switch (data.recipient) {
      FcmMessageChannelRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic',
      FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}',
    };
    return '$groupKey|$conversation';
  }
```

[^1]: As they are in the message list in the web app. By using "Copy link to message", you can see that [this](https://chat.zulip.org/#narrow/channel/7-test-here/topic/move.20to.20Zulip/near/2016957) and [this](https://chat.zulip.org/#narrow/channel/7-test-here/topic/move.20to.20zulip/near/2016958) message have those topics that only differ by case. But they both appear in the message list as though they both have topic "move to Zulip". <img width="310" alt="image" src="https://github.com/user-attachments/assets/9ecdd47c-009f-48b4-b47c-680660a109a7" />


Discussion hints:
> See our notification-display code for getting a message's "conversation key". It just takes the topic literally without normalizing it by case:

(I assume the server isn't normalizing it for us.)
Generally, the server always tries to preserve case when passing data around, since you often do want to display with the case the user entered, even when normalizing for comparisons. So doing `.lower()` needs to happen in clients where they are trying to group messages by topic, rather than display topics.

Pull request: Distinguish a TopicName type from plain String
Toward #1250.
Fixes #1205.

This PR introduces a new type `TopicName` as a Dart "extension type":
https://dart.dev/language/extension-types
At runtime it's just a String — so there's no performance cost to it — but the type-checker tracks the distinction.

This lets us readily find all the spots in the code that need to adapt to show "*general chat*" for #1250: it's exactly the use sites of the new `TopicName.displayName`. (With one possible loophole involving string interpolation; see the last commit message below.)

This series is nearly NFC; each commit is NFC except the first two prep commits, which are nearly so.

## Selected commit messages

api: Explicitly ignore topics on DMs

The topic/subject field on DMs carries no information in the current
API, and we already never actually look at it, as demonstrated by
the lack of changes in the rest of the tree in this commit.  Clean
up the type definitions in the API a bit by making that explicit.

This is NFC except in the case where a server were to not send this
field at all for a DM (or to send a non-string value).  In that case
the old code would reject the server's response as malformed, and
the new code after this commit would accept it because it doesn't
look for the field.

---

api [nfc]: Introduce TopicName extension type

We'll use this to make a type-level distinction between a string
that's being used to name a topic, and just any old string.
That will help us in particular with #1250 the "general chat"
feature, where the way we show a topic name to the user stops being
necessarily just the same string that it appears as in the API.

The next phase will be to migrate a bunch of places in our code
to refer to TopicName when that's what they mean, instead of just
String.

During this phase, a TopicName can be freely used as a String, but
not vice versa.  So we'll do the migrations mostly in order of the
data flow, from upstream to downstream.  That will allow us to do
them over a series of individually coherent commits, with a minimum
of occasions where we temporarily introduce a conversion that won't
be needed in the final result.

That means: first, test data; then topics returned from the API to
our code; then our internal models; then back to the API, this time
for topics passed to the API from our code.

After we have the type in place all over where it belongs, we'll
start making use of the distinction, and then enforcing it.

---

api [nfc]: Use TopicName type across request parameters

This completes the conversion to use the TopicName type in the
places where it belongs.

Next, we'll start making use of this new type distinction: instead
of using a TopicName implicitly as a String, we'll introduce members
corresponding to the different ways a topic name is used which are
going to start having different behavior in the future.

After that's complete, we can start enforcing the distinction in
order to catch accidental future implicit uses as a String.

---

api [nfc]: Introduce TopicName.apiName, and use for making API requests

---

api [nfc]: Use TopicName.apiName for constructing narrow links

---

notif [nfc]: Use TopicName.apiName for encoding NotificationOpenPayload

This isn't part of the Zulip server API -- it's entirely between the
`buildUrl` and `parseUrl` of this class, and we could make up whatever
encoding we like.  But the API name of the topic is the natural thing
to use.

---

api [nfc]: Factor out TopicName.canonicalize

---

api [nfc]: Introduce TopicName.displayName, and use where needed

Each of these call sites will need to be updated for #1250.  Those
sites can now be found by listing the references to this getter.

As a bonus the type-checker will point out most of the places that
need updates, because this getter will become nullable.  (The
exceptions are where it's used in a string interpolation, because
`null.toString()` is valid and returns "null".)

---

api [nfc]: Seal the TopicName migration by making non-transparent

We're now explicitly using apiName, canonicalize, or displayName in
each of the places where we had been implicitly treating a topic name
as a String.  Let the type-checker catch any future regressions on
that front, by removing "implements String".

More precisely, I *think* we've covered each such place.  The
loophole is that Object methods can still be called, including
toString, so an interpolation like "${channelName} > ${topic}" won't
be flagged by the analyzer.  I found some such interpolations with a
grep, but that's necessarily heuristic and there could be more.
So the analyzer won't give us quite as much help here as we'd like;
but it'll give quite a bit, and this is the most I see how to do.

Without the "implements String", a TopicName value can no longer be
implicitly converted to String or have String methods called on it.
Instead the type-checker will require any code that has such a value
to call one of the members we've declared on TopicName (or a member
of Object) in order to do anything with it.  That way the code will
be explicit about whether it needs the API name, or the display
name, or the canonicalized form for making equality comparisons.

And that in turn will enable us to make "display name" behave
differently from "API name", for #1250 the "general chat" feature,
with a reliable way of tracking down which sites need which version.


Test command: flutter test test/api/model/events_test.dart test/api/model/initial_snapshot_test.dart test/api/model/model_test.dart test/api/notifications_test.dart test/api/route/channels_test.dart test/api/route/messages_test.dart test/api/route/typing_test.dart test/model/autocomplete_test.dart test/model/channel_test.dart test/model/internal_link_test.dart test/model/message_list_test.dart test/model/message_test.dart test/model/recent_senders_test.dart test/model/store_test.dart test/model/typing_status_test.dart test/model/unreads_test.dart test/notifications/display_test.dart test/widgets/action_sheet_test.dart test/widgets/autocomplete_test.dart test/widgets/compose_box_test.dart test/widgets/message_list_test.dart test/widgets/subscription_list_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 837aab120f..f543144ce1 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -645,7 +645,7 @@ class UserTopicEvent extends Event {
   String get type => 'user_topic';
 
   final int streamId;
-  final String topicName;
+  final TopicName topicName;
   final int lastUpdated;
   final UserTopicVisibilityPolicy visibilityPolicy;
 
@@ -725,9 +725,9 @@ class UpdateMessageEvent extends Event {
   final PropagateMode? propagateMode;
 
   @JsonKey(name: 'orig_subject')
-  final String? origTopic;
+  final TopicName? origTopic;
   @JsonKey(name: 'subject')
-  final String? newTopic;
+  final TopicName? newTopic;
 
   // final List<TopicLink> topicLinks; // TODO handle
 
@@ -788,7 +788,7 @@ class DeleteMessageEvent extends Event {
   @MessageTypeConverter()
   final MessageType messageType;
   final int? streamId;
-  final String? topic;
+  final TopicName? topic;
 
   DeleteMessageEvent({
     required super.id,
@@ -924,7 +924,7 @@ class UpdateMessageFlagsMessageDetail {
   final bool? mentioned;
   final List<int>? userIds;
   final int? streamId;
-  final String? topic;
+  final TopicName? topic;
 
   UpdateMessageFlagsMessageDetail({
     required this.type,
@@ -1002,7 +1002,7 @@ class TypingEvent extends Event {
   @JsonKey(name: 'recipients', fromJson: _recipientIdsFromJson)
   final List<int>? recipientIds;
   final int? streamId;
-  final String? topic;
+  final TopicName? topic;
 
   TypingEvent({
     required super.id,
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 8f11b8b655..9229bf9757 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -389,7 +389,7 @@ UserTopicEvent _$UserTopicEventFromJson(Map<String, dynamic> json) =>
     UserTopicEvent(
       id: (json['id'] as num).toInt(),
       streamId: (json['stream_id'] as num).toInt(),
-      topicName: json['topic_name'] as String,
+      topicName: TopicName.fromJson(json['topic_name'] as String),
       lastUpdated: (json['last_updated'] as num).toInt(),
       visibilityPolicy: $enumDecode(
           _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy']),
@@ -430,8 +430,12 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map<String, dynamic> json) =>
       newStreamId: (json['new_stream_id'] as num?)?.toInt(),
       propagateMode:
           $enumDecodeNullable(_$PropagateModeEnumMap, json['propagate_mode']),
-      origTopic: json['orig_subject'] as String?,
-      newTopic: json['subject'] as String?,
+      origTopic: json['orig_subject'] == null
+          ? null
+          : TopicName.fromJson(json['orig_subject'] as String),
+      newTopic: json['subject'] == null
+          ? null
+          : TopicName.fromJson(json['subject'] as String),
       origContent: json['orig_content'] as String?,
       origRenderedContent: json['orig_rendered_content'] as String?,
       content: json['content'] as String?,
@@ -487,7 +491,9 @@ DeleteMessageEvent _$DeleteMessageEventFromJson(Map<String, dynamic> json) =>
       messageType:
           const MessageTypeConverter().fromJson(json['message_type'] as String),
       streamId: (json['stream_id'] as num?)?.toInt(),
-      topic: json['topic'] as String?,
+      topic: json['topic'] == null
+          ? null
+          : TopicName.fromJson(json['topic'] as String),
     );
 
 Map<String, dynamic> _$DeleteMessageEventToJson(DeleteMessageEvent instance) =>
@@ -561,7 +567,9 @@ UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson(
           ?.map((e) => (e as num).toInt())
           .toList(),
       streamId: (json['stream_id'] as num?)?.toInt(),
-      topic: json['topic'] as String?,
+      topic: json['topic'] == null
+          ? null
+          : TopicName.fromJson(json['topic'] as String),
     );
 
 Map<String, dynamic> _$UpdateMessageFlagsMessageDetailToJson(
@@ -609,7 +617,9 @@ TypingEvent _$TypingEventFromJson(Map<String, dynamic> json) => TypingEvent(
       senderId: (TypingEvent._readSenderId(json, 'sender_id') as num).toInt(),
       recipientIds: TypingEvent._recipientIdsFromJson(json['recipients']),
       streamId: (json['stream_id'] as num?)?.toInt(),
-      topic: json['topic'] as String?,
+      topic: json['topic'] == null
+          ? null
+          : TopicName.fromJson(json['topic'] as String),
     );
 
 Map<String, dynamic> _$TypingEventToJson(TypingEvent instance) =>
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 6be0cadda9..efe7a79ebc 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -231,7 +231,7 @@ class UserSettings {
 @JsonSerializable(fieldRename: FieldRename.snake)
 class UserTopicItem {
   final int streamId;
-  final String topicName;
+  final TopicName topicName;
   final int lastUpdated;
   @JsonKey(unknownEnumValue: UserTopicVisibilityPolicy.unknown)
   final UserTopicVisibilityPolicy visibilityPolicy;
@@ -310,7 +310,7 @@ class UnreadDmSnapshot {
 /// An item in [UnreadMessagesSnapshot.channels].
 @JsonSerializable(fieldRename: FieldRename.snake)
 class UnreadChannelSnapshot {
-  final String topic;
+  final TopicName topic;
   final int streamId;
   final List<int> unreadMessageIds;
 
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 75472c1d7f..445bb8fdb6 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -188,7 +188,7 @@ const _$EmojisetEnumMap = {
 UserTopicItem _$UserTopicItemFromJson(Map<String, dynamic> json) =>
     UserTopicItem(
       streamId: (json['stream_id'] as num).toInt(),
-      topicName: json['topic_name'] as String,
+      topicName: TopicName.fromJson(json['topic_name'] as String),
       lastUpdated: (json['last_updated'] as num).toInt(),
       visibilityPolicy: $enumDecode(
           _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy'],
@@ -260,7 +260,7 @@ Map<String, dynamic> _$UnreadDmSnapshotToJson(UnreadDmSnapshot instance) =>
 UnreadChannelSnapshot _$UnreadChannelSnapshotFromJson(
         Map<String, dynamic> json) =>
     UnreadChannelSnapshot(
-      topic: json['topic'] as String,
+      topic: TopicName.fromJson(json['topic'] as String),
       streamId: (json['stream_id'] as num).toInt(),
       unreadMessageIds: (json['unread_message_ids'] as List<dynamic>)
           .map((e) => (e as num).toInt())
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index b36e1f2490..e55dc1d1f0 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -556,11 +556,11 @@ sealed class Message {
   final String senderFullName;
   final int senderId;
   final String senderRealmStr;
-  @JsonKey(name: 'subject')
-  String topic;
+
   /// Poll data if "submessages" describe a poll, `null` otherwise.
   @JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson)
   Poll? poll;
+
   final int timestamp;
   String get type;
 
@@ -613,7 +613,6 @@ sealed class Message {
     required this.senderFullName,
     required this.senderId,
     required this.senderRealmStr,
-    required this.topic,
     required this.timestamp,
     required this.flags,
     required this.matchContent,
@@ -656,6 +655,38 @@ enum MessageFlag {
   String toJson() => _$MessageFlagEnumMap[this]!;
 }
 
+/// The name of a Zulip topic.
+// TODO(dart): Can we forbid calling Object members on this extension type?
+//   (The lack of "implements Object" ought to do that, but doesn't.)
+//   In particular an interpolation "foo > $topic" is a bug we'd like to catch.
+// TODO(dart): Can we forbid using this extension type as a key in a Map?
+//   (The lack of "implements Object" arguably should do that, but doesn't.)
+//   Using as a Map key is almost certainly a bug because it won't case-fold;
+//   see for example #739, #980, #1205.
+extension type const TopicName(String _value) {
+  /// The string this topic is identified by in the Zulip API.
+  ///
+  /// This should be used in constructing HTTP requests to the server,
+  /// but rarely for other purposes.  See [displayName] and [canonicalize].
+  String get apiName => _value;
+
+  /// The string this topic is displayed as to the user in our UI.
+  ///
+  /// At the moment this always equals [apiName].
+  /// In the future this will become null for the "general chat" topic (#1250),
+  /// so that UI code can identify when it needs to represent the topic
+  /// specially in the way prescribed for "general chat".
+  // TODO(#1250) carry out that plan
+  String get displayName => _value;
+
+  /// The key to use for "same topic as" comparisons.
+  String canonicalize() => apiName.toLowerCase();
+
+  TopicName.fromJson(this._value);
+
+  String toJson() => apiName;
+}
+
 @JsonSerializable(fieldRename: FieldRename.snake)
 class StreamMessage extends Message {
   @override
@@ -667,8 +698,16 @@ class StreamMessage extends Message {
   // invalidated.
   @JsonKey(required: true, disallowNullValue: true)
   String? displayRecipient;
+
   int streamId;
 
+  // The topic/subject is documented to be present on DMs too, just empty.
+  // We ignore it on DMs; if a future server introduces distinct topics in DMs,
+  // that will need new UI that we'll design then as part of that feature,
+  // and ignoring the topics seems as good a fallback behavior as any.
+  @JsonKey(name: 'subject')
+  TopicName topic;
+
   StreamMessage({
     required super.client,
     required super.content,
@@ -683,13 +722,13 @@ class StreamMessage extends Message {
     required super.senderFullName,
     required super.senderId,
     required super.senderRealmStr,
-    required super.topic,
     required super.timestamp,
     required super.flags,
     required super.matchContent,
     required super.matchTopic,
     required this.displayRecipient,
     required this.streamId,
+    required this.topic,
   });
 
   factory StreamMessage.fromJson(Map<String, dynamic> json) =>
@@ -786,7 +825,6 @@ class DmMessage extends Message {
     required super.senderFullName,
     required super.senderId,
     required super.senderRealmStr,
-    required super.topic,
     required super.timestamp,
     required super.flags,
     required super.matchContent,
@@ -817,14 +855,14 @@ enum MessageEditState {
   /// The Zulip "resolved topics" feature is implemented by renaming the topic;
   /// but for purposes of [Message.editState], we want to ignore such renames.
   /// This method identifies topic moves that should be ignored in that context.
-  static bool topicMoveWasResolveOrUnresolve(String topic, String prevTopic) {
-    if (topic.startsWith(_resolvedTopicPrefix)
-        && topic.substring(_resolvedTopicPrefix.length) == prevTopic) {
+  static bool topicMoveWasResolveOrUnresolve(TopicName topic, TopicName prevTopic) {
+    if (topic.apiName.startsWith(_resolvedTopicPrefix)
+        && topic.apiName.substring(_resolvedTopicPrefix.length) == prevTopic.apiName) {
       return true;
     }
 
-    if (prevTopic.startsWith(_resolvedTopicPrefix)
-        && prevTopic.substring(_resolvedTopicPrefix.length) == topic) {
+    if (prevTopic.apiName.startsWith(_resolvedTopicPrefix)
+        && prevTopic.apiName.substring(_resolvedTopicPrefix.length) == topic.apiName) {
       return true;
     }
 
@@ -857,8 +895,10 @@ enum MessageEditState {
       }
 
       // TODO(server-5) prev_subject was the old name of prev_topic on pre-5.0 servers
-      final prevTopic = (entry['prev_topic'] ?? entry['prev_subject']) as String?;
-      final topic = entry['topic'] as String?;
+      final prevTopicStr = (entry['prev_topic'] ?? entry['prev_subject']) as String?;
+      final prevTopic = prevTopicStr == null ? null : TopicName.fromJson(prevTopicStr);
+      final topicStr = entry['topic'] as String?;
+      final topic = topicStr == null ? null : TopicName.fromJson(topicStr);
       if (prevTopic != null) {
         // TODO(server-5) pre-5.0 servers do not have the 'topic' field
         if (topic == null) {
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index 2c5adbe15c..c8434a0fa8 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -267,13 +267,13 @@ StreamMessage _$StreamMessageFromJson(Map<String, dynamic> json) {
     senderFullName: json['sender_full_name'] as String,
     senderId: (json['sender_id'] as num).toInt(),
     senderRealmStr: json['sender_realm_str'] as String,
-    topic: json['subject'] as String,
     timestamp: (json['timestamp'] as num).toInt(),
     flags: Message._flagsFromJson(json['flags']),
     matchContent: json['match_content'] as String?,
     matchTopic: json['match_subject'] as String?,
     displayRecipient: json['display_recipient'] as String?,
     streamId: (json['stream_id'] as num).toInt(),
+    topic: TopicName.fromJson(json['subject'] as String),
   )..poll = Poll.fromJson(Message._readPoll(json, 'submessages'));
 }
 
@@ -292,7 +292,6 @@ Map<String, dynamic> _$StreamMessageToJson(StreamMessage instance) =>
       'sender_full_name': instance.senderFullName,
       'sender_id': instance.senderId,
       'sender_realm_str': instance.senderRealmStr,
-      'subject': instance.topic,
       'submessages': Poll.toJson(instance.poll),
       'timestamp': instance.timestamp,
       'flags': instance.flags,
@@ -302,6 +301,7 @@ Map<String, dynamic> _$StreamMessageToJson(StreamMessage instance) =>
       if (instance.displayRecipient case final value?)
         'display_recipient': value,
       'stream_id': instance.streamId,
+      'subject': instance.topic,
     };
 
 const _$MessageEditStateEnumMap = {
@@ -338,7 +338,6 @@ DmMessage _$DmMessageFromJson(Map<String, dynamic> json) => DmMessage(
       senderFullName: json['sender_full_name'] as String,
       senderId: (json['sender_id'] as num).toInt(),
       senderRealmStr: json['sender_realm_str'] as String,
-      topic: json['subject'] as String,
       timestamp: (json['timestamp'] as num).toInt(),
       flags: Message._flagsFromJson(json['flags']),
       matchContent: json['match_content'] as String?,
@@ -361,7 +360,6 @@ Map<String, dynamic> _$DmMessageToJson(DmMessage instance) => <String, dynamic>{
       'sender_full_name': instance.senderFullName,
       'sender_id': instance.senderId,
       'sender_realm_str': instance.senderRealmStr,
-      'subject': instance.topic,
       'submessages': Poll.toJson(instance.poll),
       'timestamp': instance.timestamp,
       'flags': instance.flags,
diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart
index 3ec08a4fe4..8082ac64ff 100644
--- a/lib/api/model/narrow.dart
+++ b/lib/api/model/narrow.dart
@@ -1,5 +1,7 @@
 import 'package:json_annotation/json_annotation.dart';
 
+import 'model.dart';
+
 part 'narrow.g.dart';
 
 typedef ApiNarrow = List<ApiNarrowElement>;
@@ -26,7 +28,37 @@ ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) {
 /// please add more as needed.
 sealed class ApiNarrowElement {
   String get operator;
-  Object get operand;
+
+  /// The operand of this narrow filter.
+  ///
+  /// The base-class getter [ApiNarrowElement.operand] returns `dynamic`,
+  /// and its value should only be used for encoding as JSON, for use in a
+  /// request to the Zulip server.
+  ///
+  /// For any operations that depend more specifically on the operand's type,
+  /// do not use run-time type checks on the value of [operand]; instead, make
+  /// a run-time type check (e.g. with `switch`) on the [ApiNarrowElement]
+  /// itself, and use the [operand] getter of the specific subtype.
+  ///
+  /// That makes a difference because [ApiNarrowTopic.operand] has type
+  /// [TopicName]; at runtime a [TopicName] is indistinguishable from [String],
+  /// but an [ApiNarrowTopic] can still be distinguished from other subclasses.
+  //
+  // We can't just write [Object] here; if we do, the compiler rejects the
+  // override in ApiNarrowTopic because TopicName can't be assigned to Object.
+  // The reason that could be bad is that a caller of [ApiNarrowElement.operand]
+  // could take the result and call Object members on it, like toString, even
+  // though TopicName doesn't declare those members.
+  //
+  // In this case that's fine because the only plausible thing to do with
+  // a generic [ApiNarrowElement.operand] is to encode it as JSON anyway,
+  // which behaves just fine on TopicName.
+  //
+  // ... Even if it weren't fine, in the case of Object this protection is
+  // thoroughly undermined already: code that has a TopicName can call Object
+  // members on it directly.  See comments at [TopicName].
+  dynamic get operand; // see justification for `dynamic` above
+
   final bool negated;
 
   ApiNarrowElement({this.negated = false});
@@ -54,12 +86,12 @@ class ApiNarrowStream extends ApiNarrowElement {
 class ApiNarrowTopic extends ApiNarrowElement {
   @override String get operator => 'topic';
 
-  @override final String operand;
+  @override final TopicName operand;
 
   ApiNarrowTopic(this.operand, {super.negated});
 
   factory ApiNarrowTopic.fromJson(Map<String, dynamic> json) => ApiNarrowTopic(
-    json['operand'] as String,
+    TopicName.fromJson(json['operand'] as String),
     negated: json['negated'] as bool? ?? false,
   );
 }
diff --git a/lib/api/notifications.dart b/lib/api/notifications.dart
index 2337c0456e..6d028aa267 100644
--- a/lib/api/notifications.dart
+++ b/lib/api/notifications.dart
@@ -1,6 +1,8 @@
 
 import 'package:json_annotation/json_annotation.dart';
 
+import 'model/model.dart';
+
 part 'notifications.g.dart';
 
 /// Parsed version of an FCM message, of any type.
@@ -187,7 +189,7 @@ class FcmMessageChannelRecipient extends FcmMessageRecipient {
   @JsonKey(name: 'stream')
   final String? streamName;
 
-  final String topic;
+  final TopicName topic;
 
   FcmMessageChannelRecipient({required this.streamId, required this.streamName, required this.topic});
 
diff --git a/lib/api/notifications.g.dart b/lib/api/notifications.g.dart
index 4a9752a9af..b56412f1fd 100644
--- a/lib/api/notifications.g.dart
+++ b/lib/api/notifications.g.dart
@@ -47,7 +47,7 @@ FcmMessageChannelRecipient _$FcmMessageChannelRecipientFromJson(
     FcmMessageChannelRecipient(
       streamId: const _IntConverter().fromJson(json['stream_id'] as String),
       streamName: json['stream'] as String?,
-      topic: json['topic'] as String,
+      topic: TopicName.fromJson(json['topic'] as String),
     );
 
 RemoveFcmMessage _$RemoveFcmMessageFromJson(Map<String, dynamic> json) =>
diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart
index 00832f7fd0..bfa46f5ab8 100644
--- a/lib/api/route/channels.dart
+++ b/lib/api/route/channels.dart
@@ -28,7 +28,7 @@ class GetStreamTopicsResult {
 @JsonSerializable(fieldRename: FieldRename.snake)
 class GetStreamTopicsEntry {
   final int maxId;
-  final String name;
+  final TopicName name;
 
   GetStreamTopicsEntry({
     required this.maxId,
@@ -46,7 +46,7 @@ class GetStreamTopicsEntry {
 // TODO(server-7): remove this and just use updateUserTopic
 Future<void> updateUserTopicCompat(ApiConnection connection, {
   required int streamId,
-  required String topic,
+  required TopicName topic,
   required UserTopicVisibilityPolicy visibilityPolicy,
 }) {
   final useLegacyApi = connection.zulipFeatureLevel! < 170;
@@ -59,7 +59,7 @@ Future<void> updateUserTopicCompat(ApiConnection connection, {
     // https://zulip.com/api/mute-topic
     return connection.patch('muteTopic', (_) {}, 'users/me/subscriptions/muted_topics', {
       'stream_id': streamId,
-      'topic': RawParameter(topic),
+      'topic': RawParameter(topic.apiName),
       'op': RawParameter(op),
     });
   } else {
@@ -76,14 +76,14 @@ Future<void> updateUserTopicCompat(ApiConnection connection, {
 // TODO(server-7) remove FL 170+ mention in doc, and the related `assert`
 Future<void> updateUserTopic(ApiConnection connection, {
   required int streamId,
-  required String topic,
+  required TopicName topic,
   required UserTopicVisibilityPolicy visibilityPolicy,
 }) {
   assert(visibilityPolicy != UserTopicVisibilityPolicy.unknown);
   assert(connection.zulipFeatureLevel! >= 170);
   return connection.post('updateUserTopic', (_) {}, 'user_topics', {
     'stream_id': streamId,
-    'topic': RawParameter(topic),
+    'topic': RawParameter(topic.apiName),
     'visibility_policy': visibilityPolicy,
   });
 }
diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart
index 561b43f005..4a5f7009c3 100644
--- a/lib/api/route/channels.g.dart
+++ b/lib/api/route/channels.g.dart
@@ -26,7 +26,7 @@ GetStreamTopicsEntry _$GetStreamTopicsEntryFromJson(
         Map<String, dynamic> json) =>
     GetStreamTopicsEntry(
       maxId: (json['max_id'] as num).toInt(),
-      name: json['name'] as String,
+      name: TopicName.fromJson(json['name'] as String),
     );
 
 Map<String, dynamic> _$GetStreamTopicsEntryToJson(
diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart
index 26fd80f3ac..ea7421733e 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -186,7 +186,7 @@ Future<SendMessageResult> sendMessage(
       StreamDestination() => {
         'type': RawParameter('stream'),
         'to': destination.streamId,
-        'topic': RawParameter(destination.topic),
+        'topic': RawParameter(destination.topic.apiName),
       },
       DmDestination() => {
         'type': supportsTypeDirect ? RawParameter('direct') : RawParameter('private'),
@@ -231,7 +231,7 @@ class StreamDestination extends MessageDestination {
   const StreamDestination(this.streamId, this.topic);
 
   final int streamId;
-  final String topic;
+  final TopicName topic;
 }
 
 /// A DM conversation, for specifying to [sendMessage].
@@ -449,10 +449,10 @@ Future<void> markStreamAsRead(ApiConnection connection, {
 // TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow
 Future<void> markTopicAsRead(ApiConnection connection, {
   required int streamId,
-  required String topicName,
+  required TopicName topicName,
 }) {
   return connection.post('markTopicAsRead', (_) {}, 'mark_topic_as_read', {
     'stream_id': streamId,
-    'topic_name': RawParameter(topicName),
+    'topic_name': RawParameter(topicName.apiName),
   });
 }
diff --git a/lib/api/route/typing.dart b/lib/api/route/typing.dart
index b9f8503172..8e2e5dae6b 100644
--- a/lib/api/route/typing.dart
+++ b/lib/api/route/typing.dart
@@ -17,7 +17,7 @@ Future<void> setTypingStatus(ApiConnection connection, {
         'type':  RawParameter(supportsTypeChannel ? 'channel' : 'stream'),
         if (supportsStreamId) 'stream_id': destination.streamId
         else                  'to': [destination.streamId],
-        'topic': RawParameter(destination.topic),
+        'topic': RawParameter(destination.topic.apiName),
       });
     case DmDestination():
       final supportsDirect = connection.zulipFeatureLevel! >= 174; // TODO(server-7)
diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart
index 5b66a6d52a..cd651bdc65 100644
--- a/lib/model/autocomplete.dart
+++ b/lib/model/autocomplete.dart
@@ -485,7 +485,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
     required Narrow narrow,
   }) {
     int? streamId;
-    String? topic;
+    TopicName? topic;
     switch (narrow) {
       case ChannelNarrow():
         streamId = narrow.streamId;
@@ -506,7 +506,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
 
   static int _compareByRelevance(User userA, User userB, {
     required int? streamId,
-    required String? topic,
+    required TopicName? topic,
     required PerAccountStore store,
   }) {
     // TODO(#234): give preference to "all", "everyone" or "stream"
@@ -543,7 +543,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
   @visibleForTesting
   static int compareByRecency(User userA, User userB, {
     required int streamId,
-    required String? topic,
+    required TopicName? topic,
     required PerAccountStore store,
   }) {
     final recentSenders = store.recentSenders;
@@ -815,7 +815,7 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
   /// The channel/stream the eventual message will be sent to.
   final int streamId;
 
-  Iterable<String> _topics = [];
+  Iterable<TopicName> _topics = [];
   bool _isFetching = false;
 
   /// Fetches topics of the current stream narrow, expected to fetch
@@ -843,7 +843,7 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
     return results;
   }
 
-  TopicAutocompleteResult? _testTopic(TopicAutocompleteQuery query, String topic) {
+  TopicAutocompleteResult? _testTopic(TopicAutocompleteQuery query, TopicName topic) {
     if (query.testTopic(topic)) {
       return TopicAutocompleteResult(topic: topic);
     }
@@ -862,9 +862,10 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
 class TopicAutocompleteQuery extends AutocompleteQuery {
   TopicAutocompleteQuery(super.raw);
 
-  bool testTopic(String topic) {
+  bool testTopic(TopicName topic) {
     // TODO(#881): Sort by match relevance, like web does.
-    return topic != raw && topic.toLowerCase().contains(raw.toLowerCase());
+    return topic.displayName != raw
+      && topic.displayName.toLowerCase().contains(raw.toLowerCase());
   }
 
   @override
@@ -883,7 +884,7 @@ class TopicAutocompleteQuery extends AutocompleteQuery {
 
 /// A topic chosen in an autocomplete interaction, via a [TopicAutocompleteView].
 class TopicAutocompleteResult extends AutocompleteResult {
-  final String topic;
+  final TopicName topic;
 
   TopicAutocompleteResult({required this.topic});
 }
diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index 3ea29d7538..3f5fbe33af 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -41,7 +41,7 @@ mixin ChannelStore {
   ///
   /// For policies directly applicable in the UI, see
   /// [isTopicVisibleInStream] and [isTopicVisible].
-  UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, String topic);
+  UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic);
 
   /// The raw data structure underlying [topicVisibilityPolicy].
   ///
@@ -51,7 +51,7 @@ mixin ChannelStore {
   /// and on the other hand being a concrete, finite data structure that
   /// can be compared using `deepEquals`.
   @visibleForTesting
-  Map<int, Map<String, UserTopicVisibilityPolicy>> get debugTopicVisibility;
+  Map<int, Map<TopicName, UserTopicVisibilityPolicy>> get debugTopicVisibility;
 
   /// Whether this topic should appear when already focusing on its stream.
   ///
@@ -63,7 +63,7 @@ mixin ChannelStore {
   ///
   /// For UI contexts that are not specific to a particular stream, see
   /// [isTopicVisible].
-  bool isTopicVisibleInStream(int streamId, String topic) {
+  bool isTopicVisibleInStream(int streamId, TopicName topic) {
     return _isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic));
   }
 
@@ -100,7 +100,7 @@ mixin ChannelStore {
   ///
   /// For UI contexts that are specific to a particular stream, see
   /// [isTopicVisibleInStream].
-  bool isTopicVisible(int streamId, String topic) {
+  bool isTopicVisible(int streamId, TopicName topic) {
     return _isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic));
   }
 
@@ -171,7 +171,7 @@ class ChannelStoreImpl with ChannelStore {
       streams.putIfAbsent(stream.streamId, () => stream);
     }
 
-    final topicVisibility = <int, Map<String, UserTopicVisibilityPolicy>>{};
+    final topicVisibility = <int, Map<TopicName, UserTopicVisibilityPolicy>>{};
     for (final item in initialSnapshot.userTopics ?? const <UserTopicItem>[]) {
       if (_warnInvalidVisibilityPolicy(item.visibilityPolicy)) {
         // Not a value we expect. Keep it out of our data structures. // TODO(log)
@@ -204,12 +204,12 @@ class ChannelStoreImpl with ChannelStore {
   final Map<int, Subscription> subscriptions;
 
   @override
-  Map<int, Map<String, UserTopicVisibilityPolicy>> get debugTopicVisibility => topicVisibility;
+  Map<int, Map<TopicName, UserTopicVisibilityPolicy>> get debugTopicVisibility => topicVisibility;
 
-  final Map<int, Map<String, UserTopicVisibilityPolicy>> topicVisibility;
+  final Map<int, Map<TopicName, UserTopicVisibilityPolicy>> topicVisibility;
 
   @override
-  UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, String topic) {
+  UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) {
     return topicVisibility[streamId]?[topic] ?? UserTopicVisibilityPolicy.none;
   }
 
diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart
index a5dd23c2a2..db11115cf3 100644
--- a/lib/model/internal_link.dart
+++ b/lib/model/internal_link.dart
@@ -1,6 +1,7 @@
 import 'package:flutter/foundation.dart';
 import 'package:json_annotation/json_annotation.dart';
 
+import '../api/model/model.dart';
 import '../api/model/narrow.dart';
 import 'narrow.dart';
 import 'store.dart';
@@ -76,7 +77,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
         final slugifiedName = _encodeHashComponent(name.replaceAll(' ', '-'));
         fragment.write('$streamId-$slugifiedName');
       case ApiNarrowTopic():
-        fragment.write(_encodeHashComponent(element.operand));
+        fragment.write(_encodeHashComponent(element.operand.apiName));
       case ApiNarrowDmModern():
         final suffix = element.operand.length >= 3 ? 'group' : 'dm';
         fragment.write('${element.operand.join(',')}-$suffix');
@@ -178,7 +179,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {
         if (topicElement != null) return null;
         final String? topic = decodeHashComponent(operand);
         if (topic == null) return null;
-        topicElement = ApiNarrowTopic(topic, negated: negated);
+        topicElement = ApiNarrowTopic(TopicName(topic), negated: negated);
 
       case _NarrowOperator.dm:
       case _NarrowOperator.pmWith:
diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index ecfb16c2b9..670785ac4e 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -352,7 +352,7 @@ mixin _MessageSequence {
 bool haveSameRecipient(Message prevMessage, Message message) {
   if (prevMessage is StreamMessage && message is StreamMessage) {
     if (prevMessage.streamId != message.streamId) return false;
-    if (prevMessage.topic.toLowerCase() != message.topic.toLowerCase()) return false;
+    if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false;
   } else if (prevMessage is DmMessage && message is DmMessage) {
     if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) {
       return false;
@@ -686,8 +686,8 @@ class MessageListView with ChangeNotifier, _MessageSequence {
   void messagesMoved({
     required int origStreamId,
     required int newStreamId,
-    required String origTopic,
-    required String newTopic,
+    required TopicName origTopic,
+    required TopicName newTopic,
     required List<int> messageIds,
     required PropagateMode propagateMode,
   }) {
diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart
index 1553750849..9e29808ceb 100644
--- a/lib/model/narrow.dart
+++ b/lib/model/narrow.dart
@@ -99,7 +99,7 @@ class TopicNarrow extends Narrow implements SendableNarrow {
   }
 
   final int streamId;
-  final String topic;
+  final TopicName topic;
 
   @override
   bool containsMessage(Message message) {
@@ -114,7 +114,7 @@ class TopicNarrow extends Narrow implements SendableNarrow {
   StreamDestination get destination => StreamDestination(streamId, topic);
 
   @override
-  String toString() => 'TopicNarrow($streamId, $topic)';
+  String toString() => 'TopicNarrow($streamId, ${topic.displayName})';
 
   @override
   bool operator ==(Object other) {
diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart
index d075d05eee..a5c4bca778 100644
--- a/lib/model/recent_senders.dart
+++ b/lib/model/recent_senders.dart
@@ -16,7 +16,7 @@ class RecentSenders {
 
   // topicSenders[streamId][topic][senderId] = MessageIdTracker
   @visibleForTesting
-  final Map<int, Map<String, Map<int, MessageIdTracker>>> topicSenders = {};
+  final Map<int, Map<TopicName, Map<int, MessageIdTracker>>> topicSenders = {};
 
   /// The latest message the given user sent to the given stream,
   /// or null if no such message is known.
@@ -29,7 +29,7 @@ class RecentSenders {
   /// or null if no such message is known.
   int? latestMessageIdOfSenderInTopic({
     required int streamId,
-    required String topic,
+    required TopicName topic,
     required int senderId,
   }) => topicSenders[streamId]?[topic]?[senderId]?.maxId;
 
@@ -38,7 +38,7 @@ class RecentSenders {
   /// The messages must be sorted by [Message.id] ascending.
   void handleMessages(List<Message> messages) {
     final messagesByUserInStream = <(int, int), QueueList<int>>{};
-    final messagesByUserInTopic = <(int, String, int), QueueList<int>>{};
+    final messagesByUserInTopic = <(int, TopicName, int), QueueList<int>>{};
     for (final message in messages) {
       if (message is! StreamMessage) continue;
       final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message;
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 58a8a70615..56aef85172 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -466,10 +466,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
   @override
   Map<int, Subscription> get subscriptions => _channels.subscriptions;
   @override
-  UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, String topic) =>
+  UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) =>
     _channels.topicVisibilityPolicy(streamId, topic);
   @override
-  Map<int, Map<String, UserTopicVisibilityPolicy>> get debugTopicVisibility =>
+  Map<int, Map<TopicName, UserTopicVisibilityPolicy>> get debugTopicVisibility =>
     _channels.debugTopicVisibility;
 
   final ChannelStoreImpl _channels;
diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart
index 4f1ddc603c..1fcea3f83c 100644
--- a/lib/model/unreads.dart
+++ b/lib/model/unreads.dart
@@ -40,7 +40,7 @@ class Unreads extends ChangeNotifier {
     required int selfUserId,
     required ChannelStore channelStore,
   }) {
-    final streams = <int, Map<String, QueueList<int>>>{};
+    final streams = <int, Map<TopicName, QueueList<int>>>{};
     final dms = <DmNarrow, QueueList<int>>{};
     final mentions = Set.of(initial.mentions);
 
@@ -86,7 +86,7 @@ class Unreads extends ChangeNotifier {
   // int count;
 
   /// Unread stream messages, as: stream ID → topic → message IDs (sorted).
-  final Map<int, Map<String, QueueList<int>>> streams;
+  final Map<int, Map<TopicName, QueueList<int>>> streams;
 
   /// Unread DM messages, as: DM narrow → message IDs (sorted).
   final Map<DmNarrow, QueueList<int>> dms;
@@ -185,7 +185,7 @@ class Unreads extends ChangeNotifier {
     return c;
   }
 
-  int countInTopicNarrow(int streamId, String topic) {
+  int countInTopicNarrow(int streamId, TopicName topic) {
     final topics = streams[streamId];
     return topics?[topic]?.length ?? 0;
   }
@@ -365,7 +365,7 @@ class Unreads extends ChangeNotifier {
               _slowRemoveAllInDms(messageIdsSet);
             }
           case UpdateMessageFlagsRemoveEvent():
-            final newlyUnreadInStreams = <int, Map<String, QueueList<int>>>{};
+            final newlyUnreadInStreams = <int, Map<TopicName, QueueList<int>>>{};
             final newlyUnreadInDms = <DmNarrow, QueueList<int>>{};
             for (final messageId in event.messages) {
               final detail = event.messageDetails![messageId];
@@ -449,12 +449,12 @@ class Unreads extends ChangeNotifier {
     );
   }
 
-  void _addLastInStreamTopic(int messageId, int streamId, String topic) {
+  void _addLastInStreamTopic(int messageId, int streamId, TopicName topic) {
     ((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId);
   }
 
   // [messageIds] must be sorted ascending and without duplicates.
-  void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, String topic) {
+  void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, TopicName topic) {
     final topics = streams[streamId] ??= {};
     topics.update(topic,
       ifAbsent: () => messageIds,
@@ -469,7 +469,7 @@ class Unreads extends ChangeNotifier {
   void _slowRemoveAllInStreams(Set<int> idsToRemove) {
     final newlyEmptyStreams = <int>[];
     for (final MapEntry(key: streamId, value: topics) in streams.entries) {
-      final newlyEmptyTopics = <String>[];
+      final newlyEmptyTopics = <TopicName>[];
       for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
         messageIds.removeWhere((id) => idsToRemove.contains(id));
         if (messageIds.isEmpty) {
@@ -488,7 +488,7 @@ class Unreads extends ChangeNotifier {
     }
   }
 
-  void _removeAllInStreamTopic(Set<int> incomingMessageIds, int streamId, String topic) {
+  void _removeAllInStreamTopic(Set<int> incomingMessageIds, int streamId, TopicName topic) {
     final topics = streams[streamId];
     if (topics == null) return;
     final messageIds = topics[topic];
diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart
index 46ea95486a..30d30e9021 100644
--- a/lib/notifications/display.dart
+++ b/lib/notifications/display.dart
@@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/widgets.dart' hide Notification;
 
+import '../api/model/model.dart';
 import '../api/notifications.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import '../host/android_notifications.dart';
@@ -263,9 +264,9 @@ class NotificationDisplayManager {
     // the first.
     messagingStyle.conversationTitle = switch (data.recipient) {
       FcmMessageChannelRecipient(:var streamName?, :var topic) =>
-        '#$streamName > $topic',
+        '#$streamName > ${topic.displayName}',
       FcmMessageChannelRecipient(:var topic) =>
-        '#(unknown channel) > $topic', // TODO get stream name from data
+        '#(unknown channel) > ${topic.displayName}', // TODO get stream name from data
       FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 =>
         zulipLocalizations.notifGroupDmConversationLabel(
           data.senderFullName, allRecipientIds.length - 2), // TODO use others' names, from data
@@ -442,7 +443,7 @@ class NotificationDisplayManager {
 
   static String _conversationKey(MessageFcmMessage data, String groupKey) {
     final conversation = switch (data.recipient) {
-      FcmMessageChannelRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic',
+      FcmMessageChannelRecipient(:var streamId, :var topic) => 'stream:$streamId:${topic.canonicalize()}',
       FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}',
     };
     return '$groupKey|$conversation';
@@ -538,8 +539,8 @@ class NotificationOpenPayload {
         case 'topic':
           final channelIdStr = url.queryParameters['channel_id']!;
           final channelId = int.parse(channelIdStr, radix: 10);
-          final topic = url.queryParameters['topic']!;
-          narrow = TopicNarrow(channelId, topic);
+          final topicStr = url.queryParameters['topic']!;
+          narrow = TopicNarrow(channelId, TopicName(topicStr));
         case 'dm':
           final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!;
           final allRecipientIds = allRecipientIdsStr.split(',')
@@ -572,7 +573,7 @@ class NotificationOpenPayload {
           TopicNarrow(streamId: var channelId, :var topic) => {
             'narrow_type': 'topic',
             'channel_id': channelId.toString(),
-            'topic': topic,
+            'topic': topic.apiName,
           },
           DmNarrow(:var allRecipientIds) => {
             'narrow_type': 'dm',
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index fca61d4810..b89e9fbdb3 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -151,7 +151,7 @@ class ActionSheetCancelButton extends StatelessWidget {
 /// Show a sheet of actions you can take on a topic.
 void showTopicActionSheet(BuildContext context, {
   required int channelId,
-  required String topic,
+  required TopicName topic,
 }) {
   final store = PerAccountStoreWidget.of(context);
   final subscription = store.subscriptions[channelId];
@@ -633,6 +633,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
 
   @override void onPressed() async {
     final zulipLocalizations = ZulipLocalizations.of(pageContext);
+    final message = this.message;
 
     var composeBoxController = findMessageListPage().composeBoxController;
     // The compose box doesn't null out its controller; it's either always null
@@ -644,7 +645,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
       && composeBoxController.topic.textNormalized == kNoTopicTopic
       && message is StreamMessage
     ) {
-      composeBoxController.topic.value = TextEditingValue(text: message.topic);
+      composeBoxController.topic.setTopic(message.topic);
     }
 
     // This inserts a "[Quoting…]" placeholder into the content input,
diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart
index a1e5289b01..ba921e7f08 100644
--- a/lib/widgets/autocomplete.dart
+++ b/lib/widgets/autocomplete.dart
@@ -321,13 +321,8 @@ class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicA
   void _onTapOption(BuildContext context, TopicAutocompleteResult option) {
     final intent = autocompleteIntent();
     if (intent == null) return;
-    final replacementString = option.topic;
-    controller.value = intent.textEditingValue.replaced(
-      TextRange(
-        start: intent.syntaxStart,
-        end: intent.textEditingValue.text.length),
-      replacementString,
-    );
+    assert(intent.syntaxStart == 0);
+    controller.setTopic(option.topic);
     contentFocusNode.requestFocus();
   }
 
@@ -339,6 +334,6 @@ class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicA
       },
       child: Padding(
         padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
-        child: Text(option.topic)));
+        child: Text(option.topic.displayName)));
   }
 }
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 114e392bc7..83d923b6b8 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -89,6 +89,10 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
         TopicValidationError.tooLong,
     ];
   }
+
+  void setTopic(TopicName newTopic) {
+    value = TextEditingValue(text: newTopic.displayName);
+  }
 }
 
 enum ContentValidationError {
@@ -486,7 +490,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
       ?? zulipLocalizations.composeBoxUnknownChannelName;
     return _ContentInput(
       narrow: widget.narrow,
-      destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized),
+      destination: TopicNarrow(widget.narrow.streamId, TopicName(_topicTextNormalized)),
       controller: widget.controller,
       hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized));
   }
@@ -546,7 +550,8 @@ class _FixedDestinationContentInput extends StatelessWidget {
         final store = PerAccountStoreWidget.of(context);
         final streamName = store.streams[streamId]?.name
           ?? zulipLocalizations.composeBoxUnknownChannelName;
-        return zulipLocalizations.composeBoxChannelContentHint(streamName, topic);
+        return zulipLocalizations.composeBoxChannelContentHint(
+          streamName, topic.displayName);
 
       case DmNarrow(otherRecipientIds: []): // The self-1:1 thread.
         return zulipLocalizations.composeBoxSelfDmContentHint;
@@ -1151,7 +1156,7 @@ class _StreamComposeBoxBody extends _ComposeBoxBody {
   @override Widget buildSendButton() => _SendButton(
     controller: controller,
     getDestination: () => StreamDestination(
-      narrow.streamId, controller.topic.textNormalized),
+      narrow.streamId, TopicName(controller.topic.textNormalized)),
   );
 }
 
diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart
index 12ec8751a1..04d5246195 100644
--- a/lib/widgets/inbox.dart
+++ b/lib/widgets/inbox.dart
@@ -132,7 +132,7 @@ class _InboxPageState extends State<InboxPageBody> with PerAccountStoreAwareStat
       });
 
     for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) {
-      final topicItems = <(String, int, bool, int)>[];
+      final topicItems = <(TopicName, int, bool, int)>[];
       int countInStream = 0;
       bool streamHasMention = false;
       for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
@@ -192,7 +192,7 @@ class _StreamSectionData extends _InboxSectionData {
   final int streamId;
   final int count;
   final bool hasMention;
-  final List<(String, int, bool, int)> items;
+  final List<(TopicName, int, bool, int)> items;
 
   const _StreamSectionData(this.streamId, this.count, this.hasMention, this.items);
 }
@@ -487,7 +487,7 @@ class _TopicItem extends StatelessWidget {
   });
 
   final int streamId;
-  final String topic;
+  final TopicName topic;
   final int count;
   final bool hasMention;
 
@@ -524,7 +524,7 @@ class _TopicItem extends StatelessWidget {
                 ),
                 maxLines: 2,
                 overflow: TextOverflow.ellipsis,
-                topic))),
+                topic.displayName))),
             const SizedBox(width: 12),
             if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign),
             // TODO(design) copies the "@" marker color; is there a better color?
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 8c32a12115..f5416e3ccf 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -334,7 +334,7 @@ class MessageListAppBarTitle extends StatelessWidget {
 
   Widget _buildTopicRow(BuildContext context, {
     required ZulipStream? stream,
-    required String topic,
+    required TopicName topic,
   }) {
     final store = PerAccountStoreWidget.of(context);
     final designVariables = DesignVariables.of(context);
@@ -344,7 +344,7 @@ class MessageListAppBarTitle extends StatelessWidget {
     return Row(
       mainAxisSize: MainAxisSize.min,
       children: [
-        Flexible(child: Text(topic, style: const TextStyle(
+        Flexible(child: Text(topic.displayName, style: const TextStyle(
           fontSize: 13,
         ).merge(weightVariableTextStyle(context)))),
         if (icon != null)
@@ -1091,7 +1091,7 @@ class StreamMessageRecipientHeader extends StatelessWidget {
       child: Row(
         children: [
           Flexible(
-            child: Text(topic,
+            child: Text(topic.displayName,
               // TODO: Give a way to see the whole topic (maybe a
               //   long-press interaction?)
               overflow: TextOverflow.ellipsis,


Test patch:
diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart
index f95c6b4362..c1fa0a117f 100644
--- a/test/api/model/events_checks.dart
+++ b/test/api/model/events_checks.dart
@@ -51,8 +51,8 @@ extension UpdateMessageEventChecks on Subject<UpdateMessageEvent> {
   Subject<int?> get origStreamId => has((e) => e.origStreamId, 'origStreamId');
   Subject<int?> get newStreamId => has((e) => e.newStreamId, 'newStreamId');
   Subject<PropagateMode?> get propagateMode => has((e) => e.propagateMode, 'propagateMode');
-  Subject<String?> get origTopic => has((e) => e.origTopic, 'origTopic');
-  Subject<String?> get newTopic => has((e) => e.newTopic, 'newTopic');
+  Subject<TopicName?> get origTopic => has((e) => e.origTopic, 'origTopic');
+  Subject<TopicName?> get newTopic => has((e) => e.newTopic, 'newTopic');
   Subject<String?> get origContent => has((e) => e.origContent, 'origContent');
   Subject<String?> get origRenderedContent => has((e) => e.origRenderedContent, 'origRenderedContent');
   Subject<String?> get content => has((e) => e.content, 'content');
@@ -77,7 +77,7 @@ extension TypingEventChecks on Subject<TypingEvent> {
   Subject<int> get senderId => has((e) => e.senderId, 'senderId');
   Subject<List<int>?> get recipientIds => has((e) => e.recipientIds, 'recipientIds');
   Subject<int?> get streamId => has((e) => e.streamId, 'streamId');
-  Subject<String?> get topic => has((e) => e.topic, 'topic');
+  Subject<TopicName?> get topic => has((e) => e.topic, 'topic');
 }
 
 extension HeartbeatEventChecks on Subject<HeartbeatEvent> {
diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart
index 0f1f52f843..51b36350cc 100644
--- a/test/api/model/events_test.dart
+++ b/test/api/model/events_test.dart
@@ -116,8 +116,8 @@ void main() {
         'orig_subject': 'foo',
         'subject': 'bar',
       }) as UpdateMessageEvent)
-        ..origTopic.equals('foo')
-        ..newTopic.equals('bar');
+        ..origTopic.equals(const TopicName('foo'))
+        ..newTopic.equals(const TopicName('bar'));
     });
   });
 
diff --git a/test/api/model/initial_snapshot_test.dart b/test/api/model/initial_snapshot_test.dart
index 7b441de779..3228602fff 100644
--- a/test/api/model/initial_snapshot_test.dart
+++ b/test/api/model/initial_snapshot_test.dart
@@ -1,6 +1,7 @@
 import 'package:checks/checks.dart';
 import 'package:test/scaffolding.dart';
 import 'package:zulip/api/model/initial_snapshot.dart';
+import 'package:zulip/api/model/model.dart';
 
 import '../../stdlib_checks.dart';
 
@@ -20,7 +21,7 @@ void main() {
 
     check(snapshot.channels).single.jsonEquals(
       UnreadChannelSnapshot(
-        topic: 'topic name', streamId: 1,
+        topic: const TopicName('topic name'), streamId: 1,
         unreadMessageIds: [1, 2]));
   });
 
diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index 6a3eee8c8c..8b39b1ad57 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -38,7 +38,6 @@ extension MessageChecks on Subject<Message> {
   Subject<String> get senderFullName => has((e) => e.senderFullName, 'senderFullName');
   Subject<int> get senderId => has((e) => e.senderId, 'senderId');
   Subject<String> get senderRealmStr => has((e) => e.senderRealmStr, 'senderRealmStr');
-  Subject<String> get topic => has((e) => e.topic, 'topic');
   Subject<Poll?> get poll => has((e) => e.poll, 'poll');
   Subject<int> get timestamp => has((e) => e.timestamp, 'timestamp');
   Subject<String> get type => has((e) => e.type, 'type');
@@ -47,8 +46,14 @@ extension MessageChecks on Subject<Message> {
   Subject<String?> get matchTopic => has((e) => e.matchTopic, 'matchTopic');
 }
 
+extension TopicNameChecks on Subject<TopicName> {
+  Subject<String> get apiName => has((x) => x.apiName, 'apiName');
+  Subject<String> get displayName => has((x) => x.displayName, 'displayName');
+}
+
 extension StreamMessageChecks on Subject<StreamMessage> {
   Subject<String?> get displayRecipient => has((e) => e.displayRecipient, 'displayRecipient');
+  Subject<TopicName> get topic => has((e) => e.topic, 'topic');
 }
 
 extension ReactionsChecks on Subject<Reactions> {
diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart
index b3636b5ed6..5013b9c6c1 100644
--- a/test/api/model/model_test.dart
+++ b/test/api/model/model_test.dart
@@ -89,7 +89,8 @@ void main() {
       check(baseStreamJson()).not((it) => it.containsKey('topic'));
       check(Message.fromJson(baseStreamJson()
         ..['subject'] = 'hello'
-      )).topic.equals('hello');
+      )).isA<StreamMessage>()
+        .topic.equals(const TopicName('hello'));
     });
 
     test('match_subject -> matchTopic', () {
diff --git a/test/api/notifications_test.dart b/test/api/notifications_test.dart
index fd4465f33a..53240d7b09 100644
--- a/test/api/notifications_test.dart
+++ b/test/api/notifications_test.dart
@@ -1,5 +1,6 @@
 import 'package:checks/checks.dart';
 import 'package:test/scaffolding.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/notifications.dart';
 
 import '../stdlib_checks.dart';
@@ -81,7 +82,7 @@ void main() {
         ..recipient.isA<FcmMessageChannelRecipient>().which((it) => it
           ..streamId.equals(42)
           ..streamName.equals(streamJson['stream']!)
-          ..topic.equals(streamJson['topic']!))
+          ..topic.jsonEquals(streamJson['topic']!))
         ..content.equals(streamJson['content']!)
         ..time.equals(1546300800);
 
@@ -279,7 +280,7 @@ extension MessageFcmMessageChecks on Subject<MessageFcmMessage> {
 extension FcmMessageChannelRecipientChecks on Subject<FcmMessageChannelRecipient> {
   Subject<int> get streamId => has((x) => x.streamId, 'streamId');
   Subject<String?> get streamName => has((x) => x.streamName, 'streamName');
-  Subject<String> get topic => has((x) => x.topic, 'topic');
+  Subject<TopicName> get topic => has((x) => x.topic, 'topic');
 }
 
 extension FcmMessageDmRecipientChecks on Subject<FcmMessageDmRecipient> {
diff --git a/test/api/route/channels_test.dart b/test/api/route/channels_test.dart
index d86c9447fe..011dc508c5 100644
--- a/test/api/route/channels_test.dart
+++ b/test/api/route/channels_test.dart
@@ -12,7 +12,7 @@ void main() {
     return FakeApiConnection.with_((connection) async {
       connection.prepare(json: {});
       await updateUserTopic(connection,
-        streamId: 1, topic: 'topic',
+        streamId: 1, topic: const TopicName('topic'),
         visibilityPolicy: UserTopicVisibilityPolicy.followed);
       check(connection.takeRequests()).single.isA<http.Request>()
         ..method.equals('POST')
@@ -28,7 +28,7 @@ void main() {
   test('updateUserTopic only accepts valid visibility policy', () {
     return FakeApiConnection.with_((connection) async {
       check(() => updateUserTopic(connection,
-        streamId: 1, topic: 'topic',
+        streamId: 1, topic: const TopicName('topic'),
         visibilityPolicy: UserTopicVisibilityPolicy.unknown),
       ).throws<AssertionError>();
     });
@@ -38,7 +38,7 @@ void main() {
     return FakeApiConnection.with_((connection) async {
       connection.prepare(json: {});
       await updateUserTopicCompat(connection,
-        streamId: 1, topic: 'topic',
+        streamId: 1, topic: const TopicName('topic'),
         visibilityPolicy: UserTopicVisibilityPolicy.followed);
       check(connection.takeRequests()).single.isA<http.Request>()
         ..method.equals('POST')
@@ -55,7 +55,7 @@ void main() {
     test('updateUserTopic throws AssertionError when FL < 170', () {
       return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async {
         check(() => updateUserTopic(connection,
-          streamId: 1, topic: 'topic',
+          streamId: 1, topic: const TopicName('topic'),
           visibilityPolicy: UserTopicVisibilityPolicy.muted),
         ).throws<AssertionError>();
       });
@@ -64,7 +64,7 @@ void main() {
     test('updateUserTopicCompat throws UnsupportedError on unsupported policy', () {
       return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async {
         check(() => updateUserTopicCompat(connection,
-          streamId: 1, topic: 'topic',
+          streamId: 1, topic: const TopicName('topic'),
           visibilityPolicy: UserTopicVisibilityPolicy.followed),
         ).throws<UnsupportedError>();
       });
@@ -74,7 +74,7 @@ void main() {
       return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async {
         connection.prepare(json: {});
         await updateUserTopicCompat(connection,
-          streamId: 1, topic: 'topic',
+          streamId: 1, topic: const TopicName('topic'),
           visibilityPolicy: UserTopicVisibilityPolicy.none);
         check(connection.takeRequests()).single.isA<http.Request>()
           ..method.equals('PATCH')
@@ -91,7 +91,7 @@ void main() {
       return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async {
         connection.prepare(json: {});
         await updateUserTopicCompat(connection,
-          streamId: 1, topic: 'topic',
+          streamId: 1, topic: const TopicName('topic'),
           visibilityPolicy: UserTopicVisibilityPolicy.muted);
         check(connection.takeRequests()).single.isA<http.Request>()
           ..method.equals('PATCH')
diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart
index c634c87ff7..eb8fbdac29 100644
--- a/test/api/route/messages_test.dart
+++ b/test/api/route/messages_test.dart
@@ -184,7 +184,7 @@ void main() {
       checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([
         {'operator': 'stream', 'operand': 12},
       ]));
-      checkNarrow(const TopicNarrow(12, 'stuff').apiEncode(), jsonEncode([
+      checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([
         {'operator': 'stream', 'operand': 12},
         {'operator': 'topic', 'operand': 'stuff'},
       ]));
@@ -328,7 +328,7 @@ void main() {
     test('smoke', () {
       return FakeApiConnection.with_((connection) async {
         await checkSendMessage(connection,
-          destination: const StreamDestination(streamId, topic), content: content,
+          destination: StreamDestination(streamId, eg.t(topic)), content: content,
           queueId: 'abc:123',
           localId: '456',
           readBySender: true,
@@ -347,7 +347,7 @@ void main() {
     test('to stream', () {
       return FakeApiConnection.with_((connection) async {
         await checkSendMessage(connection,
-          destination: const StreamDestination(streamId, topic), content: content,
+          destination: StreamDestination(streamId, eg.t(topic)), content: content,
           readBySender: true,
           expectedBodyFields: {
             'type': 'stream',
@@ -391,7 +391,7 @@ void main() {
     test('when readBySender is null, sends a User-Agent we know the server will recognize', () {
       return FakeApiConnection.with_((connection) async {
         await checkSendMessage(connection,
-          destination: const StreamDestination(streamId, topic), content: content,
+          destination: StreamDestination(streamId, eg.t(topic)), content: content,
           readBySender: null,
           expectedBodyFields: {
             'type': 'stream',
@@ -406,7 +406,7 @@ void main() {
     test('legacy: when server does not support readBySender, sends a User-Agent the server will recognize', () {
       return FakeApiConnection.with_(zulipFeatureLevel: 235, (connection) async {
         await checkSendMessage(connection,
-          destination: const StreamDestination(streamId, topic), content: content,
+          destination: StreamDestination(streamId, eg.t(topic)), content: content,
           readBySender: true,
           expectedBodyFields: {
             'type': 'stream',
@@ -743,7 +743,7 @@ void main() {
     }) async {
       connection.prepare(json: {});
       await markTopicAsRead(connection,
-        streamId: streamId, topicName: topicName);
+        streamId: streamId, topicName: eg.t(topicName));
       check(connection.lastRequest).isA<http.Request>()
         ..method.equals('POST')
         ..url.path.equals('/api/v1/mark_topic_as_read')
diff --git a/test/api/route/typing_test.dart b/test/api/route/typing_test.dart
index 0d933e6f54..551f1a5a4e 100644
--- a/test/api/route/typing_test.dart
+++ b/test/api/route/typing_test.dart
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
 import 'package:checks/checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/route/messages.dart';
 import 'package:zulip/api/route/typing.dart';
 
@@ -31,7 +32,7 @@ void main() {
   Future<void> checkSetTypingStatusForTopic(TypingOp op, String expectedOp) {
     return FakeApiConnection.with_((connection) {
       return checkSetTypingStatus(connection, op,
-        destination: const StreamDestination(streamId, topic),
+        destination: const StreamDestination(streamId, TopicName(topic)),
         expectedBodyFields: {
           'op': expectedOp,
           'type': 'channel',
@@ -64,7 +65,7 @@ void main() {
   test('legacy: use "stream" instead of "channel"', () {
     return FakeApiConnection.with_(zulipFeatureLevel: 247, (connection) {
       return checkSetTypingStatus(connection, TypingOp.start,
-        destination: const StreamDestination(streamId, topic),
+        destination: const StreamDestination(streamId, TopicName(topic)),
         expectedBodyFields: {
           'op': 'start',
           'type': 'stream',
@@ -77,7 +78,7 @@ void main() {
   test('legacy: use to=[streamId] instead of stream_id=streamId', () {
     return FakeApiConnection.with_(zulipFeatureLevel: 214, (connection) {
       return checkSetTypingStatus(connection, TypingOp.start,
-        destination: const StreamDestination(streamId, topic),
+        destination: const StreamDestination(streamId, TopicName(topic)),
         expectedBodyFields: {
           'op': 'start',
           'type': 'stream',
diff --git a/test/example_data.dart b/test/example_data.dart
index d071307ec4..0e45321632 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -241,7 +241,8 @@ const _stream = stream;
 
 GetStreamTopicsEntry getStreamTopicsEntry({int? maxId, String? name}) {
   maxId ??= 123;
-  return GetStreamTopicsEntry(maxId: maxId, name: name ?? 'Test Topic #$maxId');
+  return GetStreamTopicsEntry(maxId: maxId,
+    name: TopicName(name ?? 'Test Topic #$maxId'));
 }
 
 /// Construct an example subscription from a stream.
@@ -283,11 +284,20 @@ Subscription subscription(
   );
 }
 
+/// The [TopicName] constructor, but shorter.
+///
+/// Useful in test code that mentions a lot of topics in a compact format.
+TopicName t(String apiName) => TopicName(apiName);
+
+TopicNarrow topicNarrow(int channelId, String topicName) {
+  return TopicNarrow(channelId, TopicName(topicName));
+}
+
 UserTopicItem userTopicItem(
     ZulipStream stream, String topic, UserTopicVisibilityPolicy policy) {
   return UserTopicItem(
     streamId: stream.streamId,
-    topicName: topic,
+    topicName: TopicName(topic),
     lastUpdated: 1234567890,
     visibilityPolicy: policy,
   );
@@ -519,6 +529,18 @@ Submessage submessage({
 // Aggregate data structures.
 //
 
+UnreadChannelSnapshot unreadChannelMsgs({
+  required String topic,
+  required int streamId,
+  required List<int> unreadMessageIds,
+}) {
+  return UnreadChannelSnapshot(
+    topic: TopicName(topic),
+    streamId: streamId,
+    unreadMessageIds: unreadMessageIds,
+  );
+}
+
 UnreadMessagesSnapshot unreadMsgs({
   int? count,
   List<UnreadDmSnapshot>? dms,
@@ -547,7 +569,7 @@ UserTopicEvent userTopicEvent(
   return UserTopicEvent(
     id: 1,
     streamId: streamId,
-    topicName: topic,
+    topicName: TopicName(topic),
     lastUpdated: 1234567890,
     visibilityPolicy: visibilityPolicy,
   );
@@ -605,8 +627,8 @@ UpdateMessageEvent _updateMessageMoveEvent(
   List<int> messageIds, {
   required int origStreamId,
   int? newStreamId,
-  required String origTopic,
-  String? newTopic,
+  required TopicName origTopic,
+  TopicName? newTopic,
   String? origContent,
   String? newContent,
   required List<MessageFlag> flags,
@@ -642,12 +664,15 @@ UpdateMessageEvent _updateMessageMoveEvent(
 UpdateMessageEvent updateMessageEventMoveFrom({
   required List<StreamMessage> origMessages,
   int? newStreamId,
-  String? newTopic,
+  TopicName? newTopic,
+  String? newTopicStr,
   String? newContent,
   PropagateMode propagateMode = PropagateMode.changeOne,
 }) {
   _checkPositive(newStreamId, 'stream ID');
   assert(origMessages.isNotEmpty);
+  assert(newTopic == null || newTopicStr == null);
+  newTopic ??= newTopicStr == null ? null : TopicName(newTopicStr);
   final origMessage = origMessages.first;
   // Only present on content change.
   final origContent = (newContent != null) ? origMessage.content : null;
@@ -667,12 +692,15 @@ UpdateMessageEvent updateMessageEventMoveFrom({
 UpdateMessageEvent updateMessageEventMoveTo({
   required List<StreamMessage> newMessages,
   int? origStreamId,
-  String? origTopic,
+  TopicName? origTopic,
+  String? origTopicStr,
   String? origContent,
   PropagateMode propagateMode = PropagateMode.changeOne,
 }) {
   _checkPositive(origStreamId, 'stream ID');
   assert(newMessages.isNotEmpty);
+  assert(origTopic == null || origTopicStr == null);
+  origTopic ??= origTopicStr == null ? null : TopicName(origTopicStr);
   final newMessage = newMessages.first;
   // Only present on topic move.
   final newTopic = (origTopic != null) ? newMessage.topic : null;
diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart
index 4e4ae7d986..505f5189f2 100644
--- a/test/flutter_checks.dart
+++ b/test/flutter_checks.dart
@@ -66,6 +66,12 @@ extension TextChecks on Subject<Text> {
   Subject<TextStyle?> get style => has((t) => t.style, 'style');
 }
 
+extension TextEditingValueChecks on Subject<TextEditingValue> {
+  Subject<String> get text => has((x) => x.text, 'text');
+  Subject<TextSelection> get selection => has((x) => x.selection, 'selection');
+  Subject<TextRange> get composing => has((x) => x.composing, 'composing');
+}
+
 extension TextEditingControllerChecks on Subject<TextEditingController> {
   Subject<String?> get text => has((t) => t.text, 'text');
 }
diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart
index cb94735894..ec8acbe500 100644
--- a/test/model/autocomplete_checks.dart
+++ b/test/model/autocomplete_checks.dart
@@ -1,4 +1,5 @@
 import 'package:checks/checks.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/autocomplete.dart';
 import 'package:zulip/widgets/compose_box.dart';
 
@@ -20,5 +21,5 @@ extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocomplete
 }
 
 extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
-  Subject<String> get topic => has((r) => r.topic, 'topic');
+  Subject<TopicName> get topic => has((r) => r.topic, 'topic');
 }
diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart
index 9d6667ca3f..d6ed9574e0 100644
--- a/test/model/autocomplete_test.dart
+++ b/test/model/autocomplete_test.dart
@@ -481,10 +481,11 @@ void main() {
       }
 
       int compareAB({required String? topic}) {
+        final realTopic = topic == null ? null : TopicName(topic);
         final resultAB = MentionAutocompleteView.compareByRecency(userA, userB,
-          streamId: stream.streamId, topic: topic, store: store);
+          streamId: stream.streamId, topic: realTopic, store: store);
         final resultBA = MentionAutocompleteView.compareByRecency(userB, userA,
-          streamId: stream.streamId, topic: topic, store: store);
+          streamId: stream.streamId, topic: realTopic, store: store);
         switch (resultAB) {
           case <0: check(resultBA).isGreaterThan(0);
           case >0: check(resultBA).isLessThan(0);
@@ -659,7 +660,7 @@ void main() {
           eg.user(fullName: 'b', isBot: true),
         ];
         final stream = eg.stream();
-        final narrow = TopicNarrow(stream.streamId, 'this');
+        final narrow = eg.topicNarrow(stream.streamId, 'this');
         await prepare(users: users, messages: [
           eg.streamMessage(sender: users[1], stream: stream, topic: 'this'),
           eg.streamMessage(sender: users[0], stream: stream, topic: 'this'),
@@ -790,7 +791,7 @@ void main() {
 
       final stream = eg.stream();
       const topic = 'topic';
-      final topicNarrow = TopicNarrow(stream.streamId, topic);
+      final topicNarrow = eg.topicNarrow(stream.streamId, topic);
 
       final users = [
         eg.user(userId: 1, fullName: 'User One'),
@@ -900,7 +901,7 @@ void main() {
 
   group('TopicAutocompleteQuery.testTopic', () {
     void doCheck(String rawQuery, String topic, bool expected) {
-      final result = TopicAutocompleteQuery(rawQuery).testTopic(topic);
+      final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic));
       expected ? check(result).isTrue() : check(result).isFalse();
     }
 
diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart
index 14a9f69ea3..f22ac7cc8f 100644
--- a/test/model/channel_test.dart
+++ b/test/model/channel_test.dart
@@ -123,14 +123,14 @@ void main() {
     group('getter topicVisibilityPolicy', () {
       test('with nothing for stream', () {
         final store = eg.store();
-        check(store.topicVisibilityPolicy(stream1.streamId, 'topic'))
+        check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic')))
           .equals(UserTopicVisibilityPolicy.none);
       });
 
       test('with nothing for topic', () async {
         final store = eg.store();
         await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted);
-        check(store.topicVisibilityPolicy(stream1.streamId, 'topic'))
+        check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic')))
           .equals(UserTopicVisibilityPolicy.none);
       });
 
@@ -142,7 +142,7 @@ void main() {
           UserTopicVisibilityPolicy.followed,
         ]) {
           await store.addUserTopic(stream1, 'topic', policy);
-          check(store.topicVisibilityPolicy(stream1.streamId, 'topic'))
+          check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic')))
             .equals(policy);
         }
       });
@@ -153,23 +153,23 @@ void main() {
         final store = eg.store();
         await store.addStream(stream1);
         await store.addSubscription(eg.subscription(stream1));
-        check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue();
-        check(store.isTopicVisible        (stream1.streamId, 'topic')).isTrue();
+        check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue();
+        check(store.isTopicVisible        (stream1.streamId, eg.t('topic'))).isTrue();
       });
 
       test('with policy none, stream muted', () async {
         final store = eg.store();
         await store.addStream(stream1);
         await store.addSubscription(eg.subscription(stream1, isMuted: true));
-        check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue();
-        check(store.isTopicVisible        (stream1.streamId, 'topic')).isFalse();
+        check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue();
+        check(store.isTopicVisible        (stream1.streamId, eg.t('topic'))).isFalse();
       });
 
       test('with policy none, stream unsubscribed', () async {
         final store = eg.store();
         await store.addStream(stream1);
-        check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue();
-        check(store.isTopicVisible        (stream1.streamId, 'topic')).isFalse();
+        check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue();
+        check(store.isTopicVisible        (stream1.streamId, eg.t('topic'))).isFalse();
       });
 
       test('with policy muted', () async {
@@ -177,8 +177,8 @@ void main() {
         await store.addStream(stream1);
         await store.addSubscription(eg.subscription(stream1));
         await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted);
-        check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isFalse();
-        check(store.isTopicVisible        (stream1.streamId, 'topic')).isFalse();
+        check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isFalse();
+        check(store.isTopicVisible        (stream1.streamId, eg.t('topic'))).isFalse();
       });
 
       test('with policy unmuted', () async {
@@ -186,8 +186,8 @@ void main() {
         await store.addStream(stream1);
         await store.addSubscription(eg.subscription(stream1, isMuted: true));
         await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted);
-        check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue();
-        check(store.isTopicVisible        (stream1.streamId, 'topic')).isTrue();
+        check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue();
+        check(store.isTopicVisible        (stream1.streamId, eg.t('topic'))).isTrue();
       });
 
       test('with policy followed', () async {
@@ -195,8 +195,8 @@ void main() {
         await store.addStream(stream1);
         await store.addSubscription(eg.subscription(stream1, isMuted: true));
         await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed);
-        check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue();
-        check(store.isTopicVisible        (stream1.streamId, 'topic')).isTrue();
+        check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue();
+        check(store.isTopicVisible        (stream1.streamId, eg.t('topic'))).isTrue();
       });
     });
 
@@ -265,16 +265,16 @@ void main() {
                   eg.subscription(stream1, isMuted: streamMuted));
               }
               await store.handleEvent(mkEvent(oldPolicy));
-              final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic');
-              final oldVisible         = store.isTopicVisible(stream1.streamId, 'topic');
+              final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'));
+              final oldVisible         = store.isTopicVisible(stream1.streamId, eg.t('topic'));
 
               final event = mkEvent(newPolicy);
               final willChangeInStream = store.willChangeIfTopicVisibleInStream(event);
               final willChange         = store.willChangeIfTopicVisible(event);
 
               await store.handleEvent(event);
-              final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic');
-              final newVisible         = store.isTopicVisible(stream1.streamId, 'topic');
+              final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'));
+              final newVisible         = store.isTopicVisible(stream1.streamId, eg.t('topic'));
 
               VisibilityEffect fromOldNew(bool oldVisible, bool newVisible) {
                 if (newVisible == oldVisible) return VisibilityEffect.none;
@@ -384,13 +384,13 @@ void main() {
           eg.userTopicItem(stream, 'topic 2', UserTopicVisibilityPolicy.unmuted),
           eg.userTopicItem(stream, 'topic 3', UserTopicVisibilityPolicy.followed),
         ]));
-      check(store.topicVisibilityPolicy(stream.streamId, 'topic 1'))
+      check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 1')))
         .equals(UserTopicVisibilityPolicy.muted);
-      check(store.topicVisibilityPolicy(stream.streamId, 'topic 2'))
+      check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 2')))
         .equals(UserTopicVisibilityPolicy.unmuted);
-      check(store.topicVisibilityPolicy(stream.streamId, 'topic 3'))
+      check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 3')))
         .equals(UserTopicVisibilityPolicy.followed);
-      check(store.topicVisibilityPolicy(stream.streamId, 'topic 4'))
+      check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 4')))
         .equals(UserTopicVisibilityPolicy.none);
     });
   });
diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart
index 3949339321..eb91e35855 100644
--- a/test/model/internal_link_test.dart
+++ b/test/model/internal_link_test.dart
@@ -72,7 +72,7 @@ void main() {
         await store.addStream(eg.stream(streamId: streamId, name: name));
         final narrow = topic == null
           ? ChannelNarrow(streamId)
-          : TopicNarrow(streamId, topic);
+          : eg.topicNarrow(streamId, topic);
         check(narrowLink(store, narrow, nearMessageId: nearMessageId))
           .equals(store.realmUrl.resolve(expectedFragment));
       }
@@ -281,28 +281,28 @@ void main() {
     });
 
     group('"/#narrow/stream/<...>/topic/<...>" returns expected TopicNarrow', () {
-      const testCases = [
-        ('/#narrow/stream/check/topic/test',                 TopicNarrow(1, 'test')),
-        ('/#narrow/stream/mobile/subject/topic/near/378333', TopicNarrow(3, 'topic')),
-        ('/#narrow/stream/mobile/subject/topic/with/1',      TopicNarrow(3, 'topic')),
-        ('/#narrow/stream/mobile/topic/topic/',              TopicNarrow(3, 'topic')),
-        ('/#narrow/stream/stream/topic/topic/near/1',        TopicNarrow(5, 'topic')),
-        ('/#narrow/stream/stream/topic/topic/with/22',       TopicNarrow(5, 'topic')),
-        ('/#narrow/stream/stream/subject/topic/near/1',      TopicNarrow(5, 'topic')),
-        ('/#narrow/stream/stream/subject/topic/with/333',    TopicNarrow(5, 'topic')),
-        ('/#narrow/stream/stream/subject/topic',             TopicNarrow(5, 'topic')),
+      final testCases = [
+        ('/#narrow/stream/check/topic/test',                 eg.topicNarrow(1, 'test')),
+        ('/#narrow/stream/mobile/subject/topic/near/378333', eg.topicNarrow(3, 'topic')),
+        ('/#narrow/stream/mobile/subject/topic/with/1',      eg.topicNarrow(3, 'topic')),
+        ('/#narrow/stream/mobile/topic/topic/',              eg.topicNarrow(3, 'topic')),
+        ('/#narrow/stream/stream/topic/topic/near/1',        eg.topicNarrow(5, 'topic')),
+        ('/#narrow/stream/stream/topic/topic/with/22',       eg.topicNarrow(5, 'topic')),
+        ('/#narrow/stream/stream/subject/topic/near/1',      eg.topicNarrow(5, 'topic')),
+        ('/#narrow/stream/stream/subject/topic/with/333',    eg.topicNarrow(5, 'topic')),
+        ('/#narrow/stream/stream/subject/topic',             eg.topicNarrow(5, 'topic')),
       ];
       testExpectedNarrows(testCases, streams: streams);
     });
 
     group('Both `stream` and `channel` can be used interchangeably', () {
-      const testCases = [
-        ('/#narrow/stream/check',                         ChannelNarrow(1)),
-        ('/#narrow/channel/check',                        ChannelNarrow(1)),
-        ('/#narrow/stream/check/topic/test',              TopicNarrow(1, 'test')),
-        ('/#narrow/channel/check/topic/test',             TopicNarrow(1, 'test')),
-        ('/#narrow/stream/check/topic/test/near/378333',  TopicNarrow(1, 'test')),
-        ('/#narrow/channel/check/topic/test/near/378333', TopicNarrow(1, 'test')),
+      final testCases = [
+        ('/#narrow/stream/check',                         const ChannelNarrow(1)),
+        ('/#narrow/channel/check',                        const ChannelNarrow(1)),
+        ('/#narrow/stream/check/topic/test',              eg.topicNarrow(1, 'test')),
+        ('/#narrow/channel/check/topic/test',             eg.topicNarrow(1, 'test')),
+        ('/#narrow/stream/check/topic/test/near/378333',  eg.topicNarrow(1, 'test')),
+        ('/#narrow/channel/check/topic/test/near/378333', eg.topicNarrow(1, 'test')),
       ];
       testExpectedNarrows(testCases, streams: streams);
     });
@@ -414,13 +414,13 @@ void main() {
         eg.stream(streamId: 2, name: 'some stream'),
         eg.stream(streamId: 3, name: 'some.stream'),
       ];
-      const testCases = [
-        ('/#narrow/stream/some_stream',                    ChannelNarrow(1)),
-        ('/#narrow/stream/some.20stream',                  ChannelNarrow(2)),
-        ('/#narrow/stream/some.2Estream',                  ChannelNarrow(3)),
-        ('/#narrow/stream/some_stream/topic/some_topic',   TopicNarrow(1, 'some_topic')),
-        ('/#narrow/stream/some_stream/topic/some.20topic', TopicNarrow(1, 'some topic')),
-        ('/#narrow/stream/some_stream/topic/some.2Etopic', TopicNarrow(1, 'some.topic')),
+      final testCases = [
+        ('/#narrow/stream/some_stream',                    const ChannelNarrow(1)),
+        ('/#narrow/stream/some.20stream',                  const ChannelNarrow(2)),
+        ('/#narrow/stream/some.2Estream',                  const ChannelNarrow(3)),
+        ('/#narrow/stream/some_stream/topic/some_topic',   eg.topicNarrow(1, 'some_topic')),
+        ('/#narrow/stream/some_stream/topic/some.20topic', eg.topicNarrow(1, 'some topic')),
+        ('/#narrow/stream/some_stream/topic/some.2Etopic', eg.topicNarrow(1, 'some.topic')),
       ];
       testExpectedNarrows(testCases, streams: streams);
     });
@@ -518,8 +518,8 @@ void main() {
           return '#narrow/stream/${stream.streamId}-${stream.name}/topic/$operand';
         }
         final testCases = [
-          (mkUrlString('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')),
-          (mkUrlString('lunch'),        TopicNarrow(stream.streamId, 'lunch')),
+          (mkUrlString('(no.20topic)'), eg.topicNarrow(stream.streamId, '(no topic)')),
+          (mkUrlString('lunch'),        eg.topicNarrow(stream.streamId, 'lunch')),
         ];
         testExpectedNarrows(testCases, streams: [stream]);
       });
@@ -529,12 +529,12 @@ void main() {
           return '#narrow/stream/${stream.name}/topic/$operand';
         }
         final testCases = [
-          (mkUrlString('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')),
-          (mkUrlString('google.2Ecom'), TopicNarrow(stream.streamId, 'google.com')),
+          (mkUrlString('(no.20topic)'), eg.topicNarrow(stream.streamId, '(no topic)')),
+          (mkUrlString('google.2Ecom'), eg.topicNarrow(stream.streamId, 'google.com')),
           (mkUrlString('google.com'),   null),
-          (mkUrlString('topic.20name'), TopicNarrow(stream.streamId, 'topic name')),
-          (mkUrlString('stream'),       TopicNarrow(stream.streamId, 'stream')),
-          (mkUrlString('topic'),        TopicNarrow(stream.streamId, 'topic')),
+          (mkUrlString('topic.20name'), eg.topicNarrow(stream.streamId, 'topic name')),
+          (mkUrlString('stream'),       eg.topicNarrow(stream.streamId, 'stream')),
+          (mkUrlString('topic'),        eg.topicNarrow(stream.streamId, 'topic')),
         ];
         testExpectedNarrows(testCases, streams: [stream]);
       });
diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart
index e92b2489b3..6a1d103c84 100644
--- a/test/model/message_list_test.dart
+++ b/test/model/message_list_test.dart
@@ -440,7 +440,7 @@ void main() {
     });
 
     test('in TopicNarrow, stay visible', () async {
-      await prepare(narrow: TopicNarrow(stream.streamId, topic));
+      await prepare(narrow: eg.topicNarrow(stream.streamId, topic));
       await prepareMutes();
       await prepareMessages(foundOldest: true, messages: [
         eg.streamMessage(id: 1, stream: stream, topic: topic),
@@ -720,7 +720,7 @@ void main() {
 
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: movedMessages,
-          newTopic: 'new',
+          newTopicStr: 'new',
         ));
         checkHasMessages(initialMessages + movedMessages);
         checkNotified(count: 2);
@@ -738,7 +738,7 @@ void main() {
 
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: movedMessages,
-          newTopic: 'new',
+          newTopicStr: 'new',
         ));
         checkHasMessages(initialMessages + movedMessages);
         checkNotified(count: 2);
@@ -752,7 +752,7 @@ void main() {
           messages: initialMessages + movedMessages,
         ).toJson());
         await store.handleEvent(eg.updateMessageEventMoveTo(
-          origTopic: 'orig topic',
+          origTopicStr: 'orig topic',
           origStreamId: otherStream.streamId,
           newMessages: movedMessages,
         ));
@@ -770,7 +770,7 @@ void main() {
 
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: movedMessages,
-          newTopic: 'new',
+          newTopicStr: 'new',
           newStreamId: otherStream.streamId,
         ));
         checkHasMessages(initialMessages);
@@ -793,7 +793,7 @@ void main() {
 
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: otherChannelMovedMessages,
-          newTopic: 'new',
+          newTopicStr: 'new',
         ));
         checkHasMessages(initialMessages);
         checkNotNotified();
@@ -807,7 +807,7 @@ void main() {
         ).toJson());
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: movedMessages,
-          newTopic: 'new',
+          newTopicStr: 'new',
           newStreamId: otherStream.streamId,
           propagateMode: propagateMode,
         ));
@@ -832,7 +832,7 @@ void main() {
     });
 
     group('in topic narrow', () {
-      final narrow = TopicNarrow(stream.streamId, 'topic');
+      final narrow = eg.topicNarrow(stream.streamId, 'topic');
       final initialMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'topic'));
       final movedMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'topic'));
       final otherTopicMovedMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'other topic'));
@@ -855,7 +855,7 @@ void main() {
             ).toJson());
             await store.handleEvent(eg.updateMessageEventMoveTo(
               origStreamId: origStreamId,
-              origTopic: origTopic,
+              origTopicStr: origTopic,
               newMessages: movedMessages,
             ));
             check(model).fetched.isFalse();
@@ -883,7 +883,7 @@ void main() {
             await store.handleEvent(eg.updateMessageEventMoveFrom(
               origMessages: movedMessages,
               newStreamId: newStreamId,
-              newTopic: newTopic,
+              newTopicStr: newTopic,
             ));
             checkHasMessages(initialMessages);
             checkNotifiedOnce();
@@ -896,7 +896,7 @@ void main() {
           await prepareNarrow(narrow, initialMessages);
 
           await store.handleEvent(eg.updateMessageEventMoveTo(
-            origTopic: 'other',
+            origTopicStr: 'other',
             newMessages: otherTopicMovedMessages,
           ));
           check(model).fetched.isTrue();
@@ -925,7 +925,7 @@ void main() {
         ).toJson());
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: movedMessages,
-          newTopic: 'new',
+          newTopicStr: 'new',
           newStreamId: otherStream.streamId,
           propagateMode: propagateMode,
         ));
@@ -937,21 +937,21 @@ void main() {
         handleMoveEvent(PropagateMode.changeOne);
         checkNotNotified();
         checkHasMessages(initialMessages);
-        check(model).narrow.equals(TopicNarrow(stream.streamId, 'topic'));
+        check(model).narrow.equals(eg.topicNarrow(stream.streamId, 'topic'));
       });
 
       test('follow to the new narrow when propagateMode = changeLater', () {
         handleMoveEvent(PropagateMode.changeLater);
         checkNotifiedOnce();
         checkHasMessages(movedMessages);
-        check(model).narrow.equals(TopicNarrow(otherStream.streamId, 'new'));
+        check(model).narrow.equals(eg.topicNarrow(otherStream.streamId, 'new'));
       });
 
       test('follow to the new narrow when propagateMode = changeAll', () {
         handleMoveEvent(PropagateMode.changeAll);
         checkNotifiedOnce();
         checkHasMessages(movedMessages);
-        check(model).narrow.equals(TopicNarrow(otherStream.streamId, 'new'));
+        check(model).narrow.equals(eg.topicNarrow(otherStream.streamId, 'new'));
       });
 
       test('handle move event before initial fetch', () => awaitFakeAsync((async) async {
@@ -969,11 +969,11 @@ void main() {
         check(model).fetched.isFalse();
         checkHasMessages([]);
         await store.handleEvent(eg.updateMessageEventMoveTo(
-          origTopic: 'topic',
+          origTopicStr: 'topic',
           newMessages: [followedMessage],
           propagateMode: PropagateMode.changeAll,
         ));
-        check(model).narrow.equals(TopicNarrow(stream.streamId, 'new'));
+        check(model).narrow.equals(eg.topicNarrow(stream.streamId, 'new'));
 
         async.elapse(const Duration(seconds: 2));
         checkHasMessages([followedMessage]);
@@ -1255,7 +1255,7 @@ void main() {
 
       int notifiedCount2 = 0;
       final model2 = MessageListView.init(store: store,
-          narrow: TopicNarrow(stream.streamId, 'hello'))
+          narrow: eg.topicNarrow(stream.streamId, 'hello'))
         ..addListener(() => notifiedCount2++);
 
       for (final m in [model1, model2]) {
@@ -1481,7 +1481,7 @@ void main() {
 
     test('in TopicNarrow', () async {
       final stream = eg.stream();
-      await prepare(narrow: TopicNarrow(stream.streamId, 'A'));
+      await prepare(narrow: eg.topicNarrow(stream.streamId, 'A'));
       await store.addStream(stream);
       await store.addSubscription(eg.subscription(stream, isMuted: true));
       await store.addUserTopic(stream, 'A', UserTopicVisibilityPolicy.muted);
diff --git a/test/model/message_test.dart b/test/model/message_test.dart
index c1fad15ac7..43f17be61a 100644
--- a/test/model/message_test.dart
+++ b/test/model/message_test.dart
@@ -320,7 +320,7 @@ void main() {
         final originalDisplayRecipient = origMessages[0].displayRecipient!;
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: origMessages,
-          newTopic:  'new topic'));
+          newTopicStr: 'new topic'));
         checkNotified(count: 2);
         check(store).messages.values.every(((message) =>
           message.isA<StreamMessage>()
@@ -332,7 +332,7 @@ void main() {
         await prepareOrigMessages(origTopic: 'new topic');
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: origMessages,
-          newTopic:  '✔ new topic'));
+          newTopicStr: '✔ new topic'));
         checkNotified(count: 2);
         check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.none)));
       });
@@ -341,7 +341,7 @@ void main() {
         await prepareOrigMessages(origTopic: '✔ new topic');
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: origMessages,
-          newTopic:  'new topic'));
+          newTopicStr: 'new topic'));
         checkNotified(count: 2);
         check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.none)));
       });
@@ -350,7 +350,7 @@ void main() {
         await prepareOrigMessages(origTopic: 'new topic');
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: origMessages,
-          newTopic:  '✔ new topic 2'));
+          newTopicStr: '✔ new topic 2'));
         checkNotified(count: 2);
         check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.moved)));
       });
@@ -359,7 +359,7 @@ void main() {
         await prepareOrigMessages(origTopic: '✔ new topic');
         await store.handleEvent(eg.updateMessageEventMoveFrom(
           origMessages: origMessages,
-          newTopic:  'new topic 2'));
+          newTopicStr: 'new topic 2'));
         checkNotified(count: 2);
         check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.moved)));
       });
diff --git a/test/model/narrow_checks.dart b/test/model/narrow_checks.dart
index 241547d782..ce65de854d 100644
--- a/test/model/narrow_checks.dart
+++ b/test/model/narrow_checks.dart
@@ -1,5 +1,6 @@
 
 import 'package:checks/checks.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/model/narrow.dart';
 import 'package:zulip/model/narrow.dart';
 
@@ -14,5 +15,5 @@ extension DmNarrowChecks on Subject<DmNarrow> {
 
 extension TopicNarrowChecks on Subject<TopicNarrow> {
   Subject<int> get streamId => has((x) => x.streamId, 'streamId');
-  Subject<String> get topic => has((x) => x.topic, 'topic');
+  Subject<TopicName> get topic => has((x) => x.topic, 'topic');
 }
diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart
index 2da516e11c..602362cbcc 100644
--- a/test/model/recent_senders_test.dart
+++ b/test/model/recent_senders_test.dart
@@ -7,7 +7,7 @@ import '../example_data.dart' as eg;
 /// [messages] should be sorted by [id] ascending.
 void checkMatchesMessages(RecentSenders model, List<Message> messages) {
   final Map<int, Map<int, Set<int>>> messagesByUserInStream = {};
-  final Map<int, Map<String, Map<int, Set<int>>>> messagesByUserInTopic = {};
+  final Map<int, Map<TopicName, Map<int, Set<int>>>> messagesByUserInTopic = {};
   for (final message in messages) {
     if (message is! StreamMessage) {
       throw UnsupportedError('Message of type ${message.runtimeType} is not expected.');
@@ -199,15 +199,15 @@ void main() {
     model.handleMessages(messages);
 
     check(model.latestMessageIdOfSenderInTopic(streamId: 1,
-      topic: 'a', senderId: 10)).equals(300);
+      topic: eg.t('a'), senderId: 10)).equals(300);
     // No message of user 20 in topic "a".
     check(model.latestMessageIdOfSenderInTopic(streamId: 1,
-      topic: 'a', senderId: 20)).equals(null);
+      topic: eg.t('a'), senderId: 20)).equals(null);
     // No message in topic "b" at all.
     check(model.latestMessageIdOfSenderInTopic(streamId: 1,
-      topic: 'b', senderId: 10)).equals(null);
+      topic: eg.t('b'), senderId: 10)).equals(null);
     // No message in stream 2 at all.
     check(model.latestMessageIdOfSenderInTopic(streamId: 2,
-      topic: 'a', senderId: 10)).equals(null);
+      topic: eg.t('a'), senderId: 10)).equals(null);
   });
 }
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index 10e8698360..c8c6b4c266 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -386,7 +386,7 @@ void main() {
       final stream = eg.stream();
       connection.prepare(json: SendMessageResult(id: 12345).toJson());
       await store.sendMessage(
-        destination: StreamDestination(stream.streamId, 'world'),
+        destination: StreamDestination(stream.streamId, eg.t('world')),
         content: 'hello');
       check(connection.takeRequests()).single.isA<http.Request>()
         ..method.equals('POST')
diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart
index 45348afb01..4061301366 100644
--- a/test/model/typing_status_test.dart
+++ b/test/model/typing_status_test.dart
@@ -35,7 +35,7 @@ void checkSetTypingStatusRequests(
           'type': 'channel',
           'op': op.toJson(),
           'stream_id': narrow.streamId.toString(),
-          'topic': narrow.topic}),
+          'topic': narrow.topic.apiName}),
         DmNarrow() => conditionTypingRequest({
           'type': 'direct',
           'op': op.toJson(),
@@ -94,7 +94,7 @@ void main() {
   }
 
   final stream = eg.stream();
-  final topicNarrow = TopicNarrow(stream.streamId, 'foo');
+  final topicNarrow = eg.topicNarrow(stream.streamId, 'foo');
 
   final dmNarrow = DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId);
   final groupNarrow = DmNarrow.withOtherUsers(
@@ -272,7 +272,7 @@ void main() {
       final channel = eg.stream();
       await store.addStream(channel);
       await store.addSubscription(eg.subscription(channel));
-      narrow = TopicNarrow(channel.streamId, 'topic');
+      narrow = eg.topicNarrow(channel.streamId, 'topic');
     }
 
     /// Prepares store and triggers a "typing started" notice.
diff --git a/test/model/unreads_checks.dart b/test/model/unreads_checks.dart
index 836e497b2b..ac4d64846a 100644
--- a/test/model/unreads_checks.dart
+++ b/test/model/unreads_checks.dart
@@ -1,10 +1,11 @@
 import 'package:checks/checks.dart';
 import 'package:collection/collection.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/unreads.dart';
 
 extension UnreadsChecks on Subject<Unreads> {
-  Subject<Map<int, Map<String, QueueList<int>>>> get streams => has((u) => u.streams, 'streams');
+  Subject<Map<int, Map<TopicName, QueueList<int>>>> get streams => has((u) => u.streams, 'streams');
   Subject<Map<DmNarrow, QueueList<int>>> get dms => has((u) => u.dms, 'dms');
   Subject<Set<int>> get mentions => has((u) => u.mentions, 'mentions');
   Subject<bool> get oldUnreadsMissing => has((u) => u.oldUnreadsMissing, 'oldUnreadsMissing');
diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart
index f5eab5a845..40e074dcaa 100644
--- a/test/model/unreads_test.dart
+++ b/test/model/unreads_test.dart
@@ -58,7 +58,7 @@ void main() {
     assert(Set.of(messages.map((m) => m.id)).length == messages.length,
       'checkMatchesMessages: duplicate messages in test input');
 
-    final Map<int, Map<String, QueueList<int>>> expectedStreams = {};
+    final Map<int, Map<TopicName, QueueList<int>>> expectedStreams = {};
     final Map<DmNarrow, QueueList<int>> expectedDms = {};
     final Set<int> expectedMentions = {};
     for (final message in messages) {
@@ -114,10 +114,10 @@ void main() {
       prepare(initial: UnreadMessagesSnapshot(
         count: 0,
         channels: [
-          UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]),
-          UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]),
-          UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]),
-          UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]),
+          eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]),
+          eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]),
+          eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]),
+          eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]),
         ],
         dms: [
           UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [9, 10]),
@@ -204,7 +204,7 @@ void main() {
       prepare();
       fillWithMessages(List.generate(7, (i) => eg.streamMessage(
         stream: stream, topic: 'a', flags: [])));
-      check(model.countInTopicNarrow(stream.streamId, 'a')).equals(7);
+      check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(7);
     });
 
     test('countInDmNarrow', () {
@@ -538,7 +538,7 @@ void main() {
         messageIds: [11, 12],
         messageType: MessageType.stream,
         streamId: stream1.streamId,
-        topic: 'a',
+        topic: eg.t('a'),
       ));
       checkNotifiedOnce();
       checkMatchesMessages(expectedRemainingMessages..removeAll([message11, message12]));
@@ -547,7 +547,7 @@ void main() {
         messageIds: [13, 14],
         messageType: MessageType.stream,
         streamId: stream2.streamId,
-        topic: 'b',
+        topic: eg.t('b'),
       ));
       checkNotifiedOnce();
       checkMatchesMessages(expectedRemainingMessages..removeAll([message13, message14]));
@@ -1029,7 +1029,7 @@ void main() {
                 type: MessageType.stream,
                 mentioned: false,
                 streamId: stream.streamId,
-                topic: topic,
+                topic: eg.t(topic),
                 userIds: null,
               ),
               // message 2 and 3 have their details missing
diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart
index cb49a5f8a8..32d8254d6d 100644
--- a/test/notifications/display_test.dart
+++ b/test/notifications/display_test.dart
@@ -540,21 +540,47 @@ void main() {
         messageStyleMessages: [data1],
         expectedIsGroupConversation: true,
         expectedTitle: '#${stream.name} > $topicA',
-        expectedTagComponent: 'stream:${stream.streamId}:$topicA');
+        expectedTagComponent: 'stream:${stream.streamId}:${topicA.toLowerCase()}');
 
       receiveFcmMessage(async, data2);
       checkNotification(data2,
         messageStyleMessages: [data2],
         expectedIsGroupConversation: true,
         expectedTitle: '#${stream.name} > $topicB',
-        expectedTagComponent: 'stream:${stream.streamId}:$topicB');
+        expectedTagComponent: 'stream:${stream.streamId}:${topicB.toLowerCase()}');
 
       receiveFcmMessage(async, data3);
       checkNotification(data3,
         messageStyleMessages: [data1, data3],
         expectedIsGroupConversation: true,
         expectedTitle: '#${stream.name} > $topicA',
-        expectedTagComponent: 'stream:${stream.streamId}:$topicA');
+        expectedTagComponent: 'stream:${stream.streamId}:${topicA.toLowerCase()}');
+    })));
+
+    test('stream message: topic changes only case', () => runWithHttpClient(() => awaitFakeAsync((async) async {
+      await init();
+      final stream = eg.stream();
+      const topic1 = 'A ToPic';
+      const topic2 = 'a TOpic';
+      final message1 = eg.streamMessage(topic: topic1, stream: stream);
+      final data1 = messageFcmMessage(message1, streamName: stream.name);
+      final message2 = eg.streamMessage(topic: topic2, stream: stream);
+      final data2 = messageFcmMessage(message2, streamName: stream.name);
+
+      receiveFcmMessage(async, data1);
+      checkNotification(data1,
+        messageStyleMessages: [data1],
+        expectedIsGroupConversation: true,
+        expectedTitle: '#${stream.name} > $topic1',
+        expectedTagComponent: 'stream:${stream.streamId}:a topic');
+
+      receiveFcmMessage(async, data2);
+      checkNotification(data2,
+        messageStyleMessages: [data1, data2],
+        expectedIsGroupConversation: true,
+        // Title updates with latest casing of topic.
+        expectedTitle: '#${stream.name} > $topic2',
+        expectedTagComponent: 'stream:${stream.streamId}:a topic');
     })));
 
     test('stream message: conversation stays same when stream is renamed', () => runWithHttpClient(() => awaitFakeAsync((async) async {
@@ -781,6 +807,7 @@ void main() {
       final data2 = messageFcmMessage(message2, streamName: stream.name);
       final message3 = eg.streamMessage(stream: stream, topic: topicA);
       final data3 = messageFcmMessage(message3, streamName: stream.name);
+      final conversationKey = 'stream:${stream.streamId}:${topicA.toLowerCase()}';
       final expectedGroupKey = '${data1.realmUrl}|${data1.userId}';
 
       check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
@@ -789,14 +816,14 @@ void main() {
       receiveFcmMessage(async, data2);
       receiveFcmMessage(async, data3);
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data3, 'stream:${stream.streamId}:$topicA'),
+        conditionActiveNotif(data3, conversationKey),
         conditionSummaryActiveNotif(expectedGroupKey),
       ]);
 
       // A RemoveFcmMessage for the first two messages; the notification stays.
       receiveFcmMessage(async, removeFcmMessage([message1, message2]));
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data3, 'stream:${stream.streamId}:$topicA'),
+        conditionActiveNotif(data3, conversationKey),
         conditionSummaryActiveNotif(expectedGroupKey),
       ]);
 
@@ -808,13 +835,17 @@ void main() {
     test('remove: clears summary notification only if all conversation notifications are cleared', () => runWithHttpClient(() => awaitFakeAsync((async) async {
       await init();
       final stream = eg.stream();
+
       const topicA = 'Topic A';
       final message1 = eg.streamMessage(stream: stream, topic: topicA);
       final data1 = messageFcmMessage(message1, streamName: stream.name);
+      final conversationKey1 = 'stream:${stream.streamId}:${topicA.toLowerCase()}';
+      final expectedGroupKey = '${data1.realmUrl}|${data1.userId}';
+
       const topicB = 'Topic B';
       final message2 = eg.streamMessage(stream: stream, topic: topicB);
       final data2 = messageFcmMessage(message2, streamName: stream.name);
-      final expectedGroupKey = '${data1.realmUrl}|${data1.userId}';
+      final conversationKey2 = 'stream:${stream.streamId}:${topicB.toLowerCase()}';
 
       check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
 
@@ -822,16 +853,16 @@ void main() {
       receiveFcmMessage(async, data1);
       receiveFcmMessage(async, data2);
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data1, 'stream:${stream.streamId}:$topicA'),
+        conditionActiveNotif(data1, conversationKey1),
         conditionSummaryActiveNotif(expectedGroupKey),
-        conditionActiveNotif(data2, 'stream:${stream.streamId}:$topicB'),
+        conditionActiveNotif(data2, conversationKey2),
       ]);
 
       // A RemoveFcmMessage for first conversation; only clears the first conversation notif.
       receiveFcmMessage(async, removeFcmMessage([message1]));
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
         conditionSummaryActiveNotif(expectedGroupKey),
-        conditionActiveNotif(data2, 'stream:${stream.streamId}:$topicB'),
+        conditionActiveNotif(data2, conversationKey2),
       ]);
 
       // Then a RemoveFcmMessage for the only remaining conversation;
@@ -844,6 +875,7 @@ void main() {
       await init();
       final stream = eg.stream();
       const topic = 'Some Topic';
+      final conversationKey = 'stream:${stream.streamId}:some topic';
 
       final account1 = eg.account(
         realmUrl: Uri.parse('https://1.chat.example'),
@@ -868,15 +900,15 @@ void main() {
       receiveFcmMessage(async, data1);
       receiveFcmMessage(async, data2);
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data1, 'stream:${stream.streamId}:$topic'),
+        conditionActiveNotif(data1, conversationKey),
         conditionSummaryActiveNotif(groupKey1),
-        conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'),
+        conditionActiveNotif(data2, conversationKey),
         conditionSummaryActiveNotif(groupKey2),
       ]);
 
       receiveFcmMessage(async, removeFcmMessage([message1], account: account1));
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'),
+        conditionActiveNotif(data2, conversationKey),
         conditionSummaryActiveNotif(groupKey2),
       ]);
 
@@ -889,6 +921,7 @@ void main() {
       final realmUrl = eg.realmUrl;
       final stream = eg.stream();
       const topic = 'Some Topic';
+      final conversationKey = 'stream:${stream.streamId}:some topic';
 
       final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl);
       final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
@@ -907,15 +940,15 @@ void main() {
       receiveFcmMessage(async, data1);
       receiveFcmMessage(async, data2);
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data1, 'stream:${stream.streamId}:$topic'),
+        conditionActiveNotif(data1, conversationKey),
         conditionSummaryActiveNotif(groupKey1),
-        conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'),
+        conditionActiveNotif(data2, conversationKey),
         conditionSummaryActiveNotif(groupKey2),
       ]);
 
       receiveFcmMessage(async, removeFcmMessage([message1], account: account1));
       check(testBinding.androidNotificationHost.activeNotifications).deepEquals(<Condition<Object?>>[
-        conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'),
+        conditionActiveNotif(data2, conversationKey),
         conditionSummaryActiveNotif(groupKey2),
       ]);
 
@@ -1116,7 +1149,7 @@ void main() {
       payload = NotificationOpenPayload(
         realmUrl: Uri.parse('http://chat.example'),
         userId: 1001,
-        narrow: const TopicNarrow(1, 'topic A'),
+        narrow: eg.topicNarrow(1, 'topic A'),
       );
       url = payload.buildUrl();
       check(NotificationOpenPayload.parseUrl(url))
@@ -1146,7 +1179,7 @@ void main() {
       final url = NotificationOpenPayload(
         realmUrl: Uri.parse('http://chat.example'),
         userId: 1001,
-        narrow: const TopicNarrow(1, 'topic A'),
+        narrow: eg.topicNarrow(1, 'topic A'),
       ).buildUrl();
       check(url)
         ..scheme.equals('zulip')
@@ -1194,7 +1227,7 @@ void main() {
         ..userId.equals(1001)
         ..narrow.which((it) => it.isA<TopicNarrow>()
           ..streamId.equals(1)
-          ..topic.equals('topic A'));
+          ..topic.equals(eg.t('topic A')));
     });
 
     test('parse: fails when missing any expected query parameters', () {
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index cf19bc3e9f..c10957363e 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -145,7 +145,7 @@ void main() {
         foundOldest: true, messages: [message]).toJson());
       await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
         child: MessageListPage(
-          initNarrow: TopicNarrow(channel.streamId, topic))));
+          initNarrow: eg.topicNarrow(channel.streamId, topic))));
       // global store, per-account store, and message list get loaded
       await tester.pumpAndSettle();
 
@@ -214,7 +214,7 @@ void main() {
           eg.streamMessage(stream: channel, topic: topic)]).toJson());
       await tester.pumpWidget(TestZulipApp(accountId: account.id,
         child: MessageListPage(
-          initNarrow: TopicNarrow(channel.streamId, topic))));
+          initNarrow: eg.topicNarrow(channel.streamId, topic))));
       await tester.pumpAndSettle();
 
       await tester.longPress(find.descendant(
@@ -809,7 +809,7 @@ void main() {
         connection.prepare(json: eg.newestGetMessagesResult(
           foundOldest: true, messages: [message]).toJson());
         await store.handleEvent(eg.updateMessageEventMoveFrom(
-          newStreamId: newStream.streamId, newTopic: newTopic,
+          newStreamId: newStream.streamId, newTopicStr: newTopic,
           propagateMode: PropagateMode.changeAll,
           origMessages: [message]));
 
@@ -823,7 +823,7 @@ void main() {
           ..method.equals('POST')
           ..url.path.equals('/api/v1/messages/flags/narrow')
           ..bodyFields['narrow'].equals(
-              jsonEncode(TopicNarrow(newStream.streamId, newTopic).apiEncode()));
+              jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode()));
       });
 
       testWidgets('shows error when fails', (tester) async {
diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart
index 24ae2dba83..a3a4b3c5f7 100644
--- a/test/widgets/autocomplete_test.dart
+++ b/test/widgets/autocomplete_test.dart
@@ -17,6 +17,7 @@ import 'package:zulip/widgets/message_list.dart';
 
 import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
+import '../flutter_checks.dart';
 import '../model/binding.dart';
 import '../model/test_store.dart';
 import '../test_images.dart';
@@ -273,7 +274,7 @@ void main() {
 
   group('TopicAutocomplete', () {
     void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) {
-      check(find.text(topic.name).evaluate().length).equals(expected ? 1 : 0);
+      check(find.text(topic.name.displayName).evaluate().length).equals(expected ? 1 : 0);
     }
 
     testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async {
@@ -298,7 +299,7 @@ void main() {
       await tester.tap(find.text('Topic three'));
       await tester.pumpAndSettle();
       check(tester.widget<TextField>(topicInputFinder).controller!.text)
-        .equals(topic3.name);
+        .equals(topic3.name.displayName);
       checkTopicShown(topic1, store, expected: false);
       checkTopicShown(topic2, store, expected: false);
       checkTopicShown(topic3, store, expected: true); // shown in `_TopicInput` once
@@ -309,5 +310,36 @@ void main() {
       await tester.pumpAndSettle();
       checkTopicShown(topic2, store, expected: true);
     });
+
+    testWidgets('text selection is reset on choosing an option', (tester) async {
+      // TODO test also that composing region gets reset.
+      //   (Just adding it to the updateEditingValue call below doesn't seem
+      //   to suffice to set it up; the controller value after the pump still
+      //   has empty composing region, so there's nothing to check after tap.)
+
+      final topic = eg.getStreamTopicsEntry(name: 'some topic');
+      final topicInputFinder = await setupToTopicInput(tester, topics: [topic]);
+      final controller = tester.widget<TextField>(topicInputFinder).controller!;
+
+      await tester.enterText(topicInputFinder, 'so');
+      await tester.enterText(topicInputFinder, 'some');
+      tester.testTextInput.updateEditingValue(const TextEditingValue(
+        text: 'some',
+        selection: TextSelection(baseOffset: 1, extentOffset: 3)));
+      await tester.pump();
+      check(controller.value)
+        ..text.equals('some')
+        ..selection.equals(
+            const TextSelection(baseOffset: 1, extentOffset: 3));
+
+      await tester.tap(find.text('some topic'));
+      await tester.pump();
+      check(controller.value)
+        ..text.equals('some topic')
+        ..selection.equals(
+            const TextSelection.collapsed(offset: 'some topic'.length));
+
+      await tester.pump(Duration.zero);
+    });
   });
 }
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index 077134e7d1..cde7133d74 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -230,14 +230,14 @@ void main() {
     testWidgets('_FixedDestinationComposeBox', (tester) async {
       final channel = eg.stream();
       await prepareComposeBox(tester,
-        narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]);
+        narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]);
       checkComposeBoxTextFields(tester, expectTopicTextField: false);
     });
   });
 
   group('ComposeBox typing notices', () {
     final channel = eg.stream();
-    final narrow = TopicNarrow(channel.streamId, 'some topic');
+    final narrow = eg.topicNarrow(channel.streamId, 'some topic');
 
     void checkTypingRequest(TypingOp op, SendableNarrow narrow) =>
       checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]);
@@ -272,9 +272,9 @@ void main() {
 
     testWidgets('smoke ChannelNarrow', (tester) async {
       final narrow = ChannelNarrow(channel.streamId);
-      final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic');
+      final destinationNarrow = eg.topicNarrow(narrow.streamId, 'test topic');
       await prepareComposeBox(tester, narrow: narrow, streams: [channel]);
-      await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic);
+      await enterTopic(tester, narrow: narrow, topic: 'test topic');
 
       await checkStartTyping(tester, destinationNarrow);
 
@@ -339,9 +339,9 @@ void main() {
 
     testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async {
       final narrow = ChannelNarrow(channel.streamId);
-      final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic');
+      final destinationNarrow = eg.topicNarrow(narrow.streamId, 'test topic');
       await prepareComposeBox(tester, narrow: narrow, streams: [channel]);
-      await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic);
+      await enterTopic(tester, narrow: narrow, topic: 'test topic');
 
       await checkStartTyping(tester, destinationNarrow);
 
@@ -402,7 +402,7 @@ void main() {
       addTearDown(TypingNotifier.debugReset);
 
       final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
-      await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'),
+      await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'),
         streams: [eg.stream(streamId: 123)]);
 
       await tester.enterText(contentInputFinder, 'hello world');
@@ -708,7 +708,7 @@ void main() {
 
       final narrowTestCases = [
         ('channel', const ChannelNarrow(1)),
-        ('topic',   const TopicNarrow(1, 'topic')),
+        ('topic',   eg.topicNarrow(1, 'topic')),
       ];
 
       for (final (String narrowType, Narrow narrow) in narrowTestCases) {
@@ -802,7 +802,7 @@ void main() {
   group('ComposeBox content input scaling', () {
     const verticalPadding = 8;
     final stream = eg.stream();
-    final narrow = TopicNarrow(stream.streamId, 'foo');
+    final narrow = eg.topicNarrow(stream.streamId, 'foo');
 
     Future<void> checkContentInputMaxHeight(WidgetTester tester, {
       required double maxHeight,
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index b1f443c8b5..c220dc0e0c 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -138,7 +138,7 @@ void main() {
       final navObserver = TestNavigatorObserver()
         ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
       final channel = eg.stream();
-      await setupMessageListPage(tester, narrow: TopicNarrow(channel.streamId, 'hi'),
+      await setupMessageListPage(tester, narrow: eg.topicNarrow(channel.streamId, 'hi'),
         navObservers: [navObserver],
         streams: [channel], messageCount: 1);
 
@@ -157,7 +157,7 @@ void main() {
       final channel = eg.stream();
       const topic = 'topic';
       await setupMessageListPage(tester,
-        narrow: TopicNarrow(channel.streamId, topic),
+        narrow: eg.topicNarrow(channel.streamId, topic),
         streams: [channel], subscriptions: [eg.subscription(channel)],
         messageCount: 1);
       await store.handleEvent(eg.userTopicEvent(
@@ -661,7 +661,7 @@ void main() {
     const topic = 'foo';
     final channel = eg.stream();
     final otherChannel = eg.stream();
-    final narrow = TopicNarrow(channel.streamId, topic);
+    final narrow = eg.topicNarrow(channel.streamId, topic);
 
     void prepareGetMessageResponse(List<Message> messages) {
       connection.prepare(json: eg.newestGetMessagesResult(
@@ -671,7 +671,7 @@ void main() {
     void handleMessageMoveEvent(List<StreamMessage> messages, String newTopic, {int? newChannelId}) {
       store.handleEvent(eg.updateMessageEventMoveFrom(
         origMessages: messages,
-        newTopic: newTopic,
+        newTopicStr: newTopic,
         newStreamId: newChannelId,
         propagateMode: PropagateMode.changeAll));
     }
@@ -749,7 +749,8 @@ void main() {
   group('recipient headers', () {
     group('StreamMessageRecipientHeader', () {
       final stream = eg.stream(name: 'stream name');
-      final message = eg.streamMessage(stream: stream, topic: 'topic name');
+      const topic = 'topic name';
+      final message = eg.streamMessage(stream: stream, topic: topic);
 
       FinderResult<Element> findInMessageList(String text) {
         // Stream name shows up in [AppBar] so need to avoid matching that
@@ -808,7 +809,7 @@ void main() {
           narrow: const CombinedFeedNarrow(),
           messages: [message], subscriptions: [eg.subscription(stream)]);
         await store.handleEvent(eg.userTopicEvent(
-          stream.streamId, message.topic, UserTopicVisibilityPolicy.followed));
+          stream.streamId, topic, UserTopicVisibilityPolicy.followed));
         await tester.pump();
         check(find.descendant(
           of: find.byType(MessageList),
@@ -820,7 +821,7 @@ void main() {
           narrow: TopicNarrow.ofMessage(message),
           messages: [message], subscriptions: [eg.subscription(stream, isMuted: true)]);
         await store.handleEvent(eg.userTopicEvent(
-          stream.streamId, message.topic, UserTopicVisibilityPolicy.unmuted));
+          stream.streamId, topic, UserTopicVisibilityPolicy.unmuted));
         await tester.pump();
         check(find.descendant(
           of: find.byType(MessageList),
@@ -1134,7 +1135,7 @@ void main() {
       checkMarkersCount(edited: 1, moved: 0);
 
       await store.handleEvent(eg.updateMessageEventMoveFrom(
-        origMessages: [message, message2], newTopic: 'new'));
+        origMessages: [message, message2], newTopicStr: 'new'));
       await tester.pump();
       checkMarkersCount(edited: 1, moved: 1);
 
diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart
index 9439bac865..a58124a533 100644
--- a/test/widgets/subscription_list_test.dart
+++ b/test/widgets/subscription_list_test.dart
@@ -155,7 +155,7 @@ void main() {
   testWidgets('unread badge shows with unreads', (tester) async {
     final stream = eg.stream();
     final unreadMsgs = eg.unreadMsgs(channels: [
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
     ]);
     await setupStreamListPage(tester, subscriptions: [
       eg.subscription(stream),
@@ -167,14 +167,14 @@ void main() {
   testWidgets('unread badge counts unmuted only', (tester) async {
     final stream = eg.stream();
     final unreadMsgs = eg.unreadMsgs(channels: [
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
     ]);
     await setupStreamListPage(tester,
       subscriptions: [eg.subscription(stream, isMuted: true)],
         userTopics: [UserTopicItem(
           streamId: stream.streamId,
-          topicName: 'b',
+          topicName: eg.t('b'),
           lastUpdated: 1234567890,
           visibilityPolicy: UserTopicVisibilityPolicy.unmuted,
         )],
@@ -198,7 +198,7 @@ void main() {
   testWidgets('muted unread badge shows when unreads are visible in channel but not inbox', (tester) async {
     final stream = eg.stream();
     final unreadMsgs = eg.unreadMsgs(channels: [
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
     ]);
     await setupStreamListPage(tester,
       subscriptions: [eg.subscription(stream, isMuted: true)],
@@ -211,7 +211,7 @@ void main() {
   testWidgets('muted unread badge does not show when unreads are visible in both channel & inbox', (tester) async {
     final stream = eg.stream();
     final unreadMsgs = eg.unreadMsgs(channels: [
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
     ]);
     await setupStreamListPage(tester,
       subscriptions: [eg.subscription(stream, isMuted: false)],
@@ -224,7 +224,7 @@ void main() {
   testWidgets('muted unread badge does not show when unreads are not visible in channel nor inbox', (tester) async {
     final stream = eg.stream();
     final unreadMsgs = eg.unreadMsgs(channels: [
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]),
     ]);
     await setupStreamListPage(tester,
       subscriptions: [eg.subscription(stream, isMuted: true)],
@@ -237,7 +237,7 @@ void main() {
   testWidgets('color propagates to icon and badge', (tester) async {
     final stream = eg.stream();
     final unreadMsgs = eg.unreadMsgs(channels: [
-      UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
+      eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
     ]);
     final subscription = eg.subscription(stream, color: Colors.red.argbInt);
     final swatch = ChannelColorSwatch.light(subscription.color);
@@ -275,8 +275,8 @@ void main() {
         eg.userTopicItem(stream2, 'b', UserTopicVisibilityPolicy.unmuted),
       ],
       unreadMsgs: eg.unreadMsgs(channels: [
-        UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]),
-        UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [3]),
+        eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]),
+        eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [3]),
       ]),
     );
 
@@ -310,10 +310,10 @@ void main() {
         eg.userTopicItem(mutedStreamWithNoUnmutedUnreads,   'd', UserTopicVisibilityPolicy.muted),
       ],
       unreadMsgs: eg.unreadMsgs(channels: [
-        UnreadChannelSnapshot(streamId: unmutedStreamWithUnmutedUnreads.streamId,   topic: 'a', unreadMessageIds: [1]),
-        UnreadChannelSnapshot(streamId: unmutedStreamWithNoUnmutedUnreads.streamId, topic: 'b', unreadMessageIds: [2]),
-        UnreadChannelSnapshot(streamId: mutedStreamWithUnmutedUnreads.streamId,     topic: 'c', unreadMessageIds: [3]),
-        UnreadChannelSnapshot(streamId: mutedStreamWithNoUnmutedUnreads.streamId,   topic: 'd', unreadMessageIds: [4]),
+        eg.unreadChannelMsgs(streamId: unmutedStreamWithUnmutedUnreads.streamId,   topic: 'a', unreadMessageIds: [1]),
+        eg.unreadChannelMsgs(streamId: unmutedStreamWithNoUnmutedUnreads.streamId, topic: 'b', unreadMessageIds: [2]),
+        eg.unreadChannelMsgs(streamId: mutedStreamWithUnmutedUnreads.streamId,     topic: 'c', unreadMessageIds: [3]),
+        eg.unreadChannelMsgs(streamId: mutedStreamWithNoUnmutedUnreads.streamId,   topic: 'd', unreadMessageIds: [4]),
       ]),
     );