Rely on Zulip Server 6 APIs

Fixes #1837.

This isn't our most urgent issue, but I was looking for some low-hanging fruit I could clear during yesterday's heat wave when it was really hot and uncomfortable at home. :)

Rely on Zulip Server 6 APIs

Rely on Zulip Server 6 APIs
Since #1521, the app refuses to connect to servers older than Zulip Server 7. That means we can simplify away all the spots in our codebase where we have logic to accommodate older versions of the Zulip server.

The previous issue #268 was for removing the code for supporting Zulip Server 4, relying on server 5, as marked by comments `TODO(server-5)`. This one is for relying on server 6, as marked by comments `TODO(server-6)`. For details, see #268 and the previous issues linked from there.

### References
* Previous round of the same thing: #268
* PR that implemented the previous round: #904
* PR that disallowed connecting to older servers (a prerequisite for this issue): #1521

flutter test test/model/channel_test.dart test/model/message_list_test.dart test/model/unreads_test.dart test/widgets/action_sheet_test.dart --reporter expanded 2>&1

diff --git a/lib/api/core.dart b/lib/api/core.dart
index b4d05b5b34..7ac3f1f852 100644
--- a/lib/api/core.dart
+++ b/lib/api/core.dart
@@ -14,7 +14,7 @@ import 'exception.dart';
 ///
 /// When updating this, also update [kMinSupportedZulipFeatureLevel]
 /// and the README.
-// TODO(#992) address all TODO(server-6) and TODO(server-7)
+// TODO(#1838) address all TODO(server-7)
 const kMinSupportedZulipVersion = '7.0';

 /// The Zulip feature level reserved for the [kMinSupportedZulipVersion] release.
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 5d86935186..9fd83c88f4 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -71,7 +71,6 @@ sealed class Event {
           case 'peer_remove': return SubscriptionPeerRemoveEvent.fromJson(json);
           default: return UnexpectedEvent.fromJson(json);
         }
-      // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers
       case 'user_status': return UserStatusEvent.fromJson(json);
       case 'user_topic': return UserTopicEvent.fromJson(json);
       case 'muted_users': return MutedUsersEvent.fromJson(json);
@@ -763,6 +762,7 @@ class SubscriptionUpdateEvent extends SubscriptionEvent {

   final int streamId;

+  @JsonKey(unknownEnumValue: SubscriptionProperty.unknown)
   final SubscriptionProperty property;

   /// The new value, or null if we don't recognize the setting.
@@ -783,7 +783,6 @@ class SubscriptionUpdateEvent extends SubscriptionEvent {
         assert(RegExp(r'^#[0-9a-f]{6}$').hasMatch(str));
         return 0xff000000 | int.parse(str.substring(1), radix: 16);
       case SubscriptionProperty.isMuted:
-      case SubscriptionProperty.inHomeView:
       case SubscriptionProperty.pinToTop:
       case SubscriptionProperty.desktopNotifications:
       case SubscriptionProperty.audibleNotifications:
@@ -820,13 +819,18 @@ enum SubscriptionProperty {
   color,

   isMuted,
-  inHomeView,
   pinToTop,
   desktopNotifications,
   audibleNotifications,
   pushNotifications,
   emailNotifications,
   wildcardMentionsNotify,
+
+  /// A new, unrecognized property, or a deprecated one we don't use.
+  ///
+  /// Could be `in_home_view`, deprecated in FL 139 (Server 6) but still sent
+  /// as of CZO on 2025-10-03.
+  // TODO(server-future) Remove `in_home_view` comment once it stops being sent.
   unknown;

   static SubscriptionProperty fromRawString(String raw) => _byRawString[raw] ?? unknown;
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 0313976018..7bf9ef0fcd 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -513,7 +513,11 @@ SubscriptionUpdateEvent _$SubscriptionUpdateEventFromJson(
 ) => SubscriptionUpdateEvent(
   id: (json['id'] as num).toInt(),
   streamId: (json['stream_id'] as num).toInt(),
-  property: $enumDecode(_$SubscriptionPropertyEnumMap, json['property']),
+  property: $enumDecode(
+    _$SubscriptionPropertyEnumMap,
+    json['property'],
+    unknownValue: SubscriptionProperty.unknown,
+  ),
   value: SubscriptionUpdateEvent._readValue(json, 'value'),
 );

@@ -531,7 +535,6 @@ Map<String, dynamic> _$SubscriptionUpdateEventToJson(
 const _$SubscriptionPropertyEnumMap = {
   SubscriptionProperty.color: 'color',
   SubscriptionProperty.isMuted: 'is_muted',
-  SubscriptionProperty.inHomeView: 'in_home_view',
   SubscriptionProperty.pinToTop: 'pin_to_top',
   SubscriptionProperty.desktopNotifications: 'desktop_notifications',
   SubscriptionProperty.audibleNotifications: 'audible_notifications',
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 9250f75fa2..83e53fa1ca 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -35,8 +35,6 @@ class InitialSnapshot {
   @JsonKey(defaultValue: 10000)
   final int serverTypingStartedWaitPeriodMilliseconds;

-  // final List<…> mutedTopics; // TODO(#422) we ignore this feature on older servers
-
   final List<MutedUserItem> mutedUsers;

   // In the modern format because we pass `slim_presence`.
@@ -70,7 +68,7 @@ class InitialSnapshot {

   final UserSettings userSettings;

-  final List<UserTopicItem>? userTopics; // TODO(server-6)
+  final List<UserTopicItem> userTopics;

   final GroupSettingValue? realmCanDeleteAnyMessageGroup; // TODO(server-10)

@@ -111,7 +109,7 @@ class InitialSnapshot {

   final int maxFileUploadSizeMib;

-  final Uri? serverEmojiDataUrl; // TODO(server-6)
+  final Uri serverEmojiDataUrl;

   final String? realmEmptyTopicDisplayName; // TODO(server-10)

@@ -284,7 +282,7 @@ class UserSettings {
   )
   TwentyFourHourTimeMode twentyFourHourTime;

-  bool? displayEmojiReactionUsers; // TODO(server-6)
+  bool displayEmojiReactionUsers;
   Emojiset emojiset;
   bool presenceEnabled;

diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index ffcbfba827..c2fd97dde5 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -78,8 +78,8 @@ InitialSnapshot _$InitialSnapshotFromJson(
   userSettings: UserSettings.fromJson(
     json['user_settings'] as Map<String, dynamic>,
   ),
-  userTopics: (json['user_topics'] as List<dynamic>?)
-      ?.map((e) => UserTopicItem.fromJson(e as Map<String, dynamic>))
+  userTopics: (json['user_topics'] as List<dynamic>)
+      .map((e) => UserTopicItem.fromJson(e as Map<String, dynamic>))
       .toList(),
   realmCanDeleteAnyMessageGroup:
       json['realm_can_delete_any_message_group'] == null
@@ -115,9 +115,7 @@ InitialSnapshot _$InitialSnapshotFromJson(
         ),
       ),
   maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(),
-  serverEmojiDataUrl: json['server_emoji_data_url'] == null
-      ? null
-      : Uri.parse(json['server_emoji_data_url'] as String),
+  serverEmojiDataUrl: Uri.parse(json['server_emoji_data_url'] as String),
   realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?,
   realmUsers:
       (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users')
@@ -186,7 +184,7 @@ Map<String, dynamic> _$InitialSnapshotToJson(
   'realm_presence_disabled': instance.realmPresenceDisabled,
   'realm_default_external_accounts': instance.realmDefaultExternalAccounts,
   'max_file_upload_size_mib': instance.maxFileUploadSizeMib,
-  'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(),
+  'server_emoji_data_url': instance.serverEmojiDataUrl.toString(),
   'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName,
   'realm_users': instance.realmUsers,
   'realm_non_active_users': instance.realmNonActiveUsers,
@@ -248,7 +246,7 @@ UserSettings _$UserSettingsFromJson(Map<String, dynamic> json) => UserSettings(
   twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue(
     json['twenty_four_hour_time'] as bool?,
   ),
-  displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?,
+  displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool,
   emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']),
   presenceEnabled: json['presence_enabled'] as bool,
 );
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 2bc6d7d487..fdfd0c856d 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -69,7 +69,7 @@ class CustomProfileField {
   final String name;
   final String hint;
   final String fieldData;
-  final bool? displayInProfileSummary; // TODO(server-6)
+  final bool? displayInProfileSummary;

   CustomProfileField({
     required this.id,
@@ -97,7 +97,7 @@ enum CustomProfileFieldType {
   link(apiValue: 5),
   user(apiValue: 6),
   externalAccount(apiValue: 7),
-  pronouns(apiValue: 8), // TODO(server-6) newly added
+  pronouns(apiValue: 8),
   unknown(apiValue: null);

   const CustomProfileFieldType({
@@ -257,7 +257,6 @@ class StatusEmoji {
 ///
 /// The absence of one of these means there is no change.
 class UserStatusChange {
-  // final Option<bool> away; // deprecated in server-6 (FL-148); ignore
   final Option<String?> text;
   final Option<StatusEmoji?> emoji;

@@ -793,7 +792,6 @@ class Subscription extends ZulipStream {

   bool pinToTop;
   bool isMuted;
-  // final bool? inHomeView; // deprecated; ignore

   /// As an int that dart:ui's Color constructor will take:
   ///   <https://api.flutter.dev/flutter/dart-ui/Color/Color.html>
diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index 098f146df1..ea05dc01d3 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -307,7 +307,7 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
     }

     final topicVisibility = <int, TopicKeyedMap<UserTopicVisibilityPolicy>>{};
-    for (final item in initialSnapshot.userTopics ?? const <UserTopicItem>[]) {
+    for (final item in initialSnapshot.userTopics) {
       if (_warnInvalidVisibilityPolicy(item.visibilityPolicy)) {
         // Not a value we expect. Keep it out of our data structures. // TODO(log)
         continue;
@@ -473,8 +473,6 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
           case SubscriptionProperty.isMuted:
             // TODO(#1255) update [MessageListView] if affected
             subscription.isMuted                = event.value as bool;
-          case SubscriptionProperty.inHomeView:
-            subscription.isMuted                = !(event.value as bool);
           case SubscriptionProperty.pinToTop:
             subscription.pinToTop               = event.value as bool;
           case SubscriptionProperty.desktopNotifications:
diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index fcb6943905..91868df2b2 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -873,12 +873,6 @@ class MessageListView with ChangeNotifier, _MessageSequence {
       numBefore: kMessageListFetchBatchSize,
       numAfter: 0,
       processResult: (result) {
-        if (result.messages.isNotEmpty
-            && result.messages.last.id == messages[0].id) {
-          // TODO(server-6): includeAnchor should make this impossible
-          result.messages.removeLast();
-        }
-
         store.reconcileMessages(result.messages);
         store.recentSenders.handleMessages(result.messages); // TODO(#824)

@@ -909,12 +903,6 @@ class MessageListView with ChangeNotifier, _MessageSequence {
       numBefore: 0,
       numAfter: kMessageListFetchBatchSize,
       processResult: (result) {
-        if (result.messages.isNotEmpty
-            && result.messages.first.id == messages.last.id) {
-          // TODO(server-6): includeAnchor should make this impossible
-          result.messages.removeAt(0);
-        }
-
         store.reconcileMessages(result.messages);
         store.recentSenders.handleMessages(result.messages); // TODO(#824)

diff --git a/lib/model/store.dart b/lib/model/store.dart
index fe503da36e..f39757343d 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -1117,12 +1117,7 @@ class UpdateMachine {
     final updateMachine = UpdateMachine.fromInitialSnapshot(
       store: store, initialSnapshot: initialSnapshot);
     updateMachine.poll();
-    if (initialSnapshot.serverEmojiDataUrl != null) {
-      // TODO(server-6): If the server is ancient, just skip trying to have
-      //   a list of its emoji.  (The old servers that don't provide
-      //   serverEmojiDataUrl are already unsupported at time of writing.)
-      unawaited(updateMachine.fetchEmojiData(initialSnapshot.serverEmojiDataUrl!));
-    }
+    unawaited(updateMachine.fetchEmojiData(initialSnapshot.serverEmojiDataUrl));
     store.presence.start();
     return updateMachine;
   }
diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart
index 616feb18ef..543cde28b6 100644
--- a/lib/model/unreads.dart
+++ b/lib/model/unreads.dart
@@ -453,12 +453,8 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
             final newlyUnreadInDms = <DmNarrow, QueueList<int>>{};
             for (final messageId in event.messages) {
               final detail = event.messageDetails![messageId];
-              if (detail == null) { // TODO(log) if on Zulip 6.0+
-                // Happens as a bug in some cases before fixed in Zulip 6.0:
-                //   https://chat.zulip.org/#narrow/stream/378-api-design/topic/unreads.20in.20unsubscribed.20streams/near/1458467
-                // TODO(server-6) remove Zulip 6.0 comment
-                continue;
-              }
+              if (detail == null) continue; // TODO(log)
+
               if (detail.mentioned == true) {
                 mentions.add(messageId);
               }
@@ -537,11 +533,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
     final topics = streams[streamId] ??= makeTopicKeyedMap();
     topics.update(topic,
       ifAbsent: () => messageIds,
-      // setUnion dedupes existing and incoming unread IDs,
-      // so we tolerate zulip/zulip#22164, fixed in 6.0
-      // TODO(server-6) remove 6.0 comment
-      (existing) => setUnion(existing, messageIds),
-    );
+      (existing) => setUnion(existing, messageIds));
   }

   /// Remove [idsToRemove] from [streams] and [dms].
@@ -640,10 +632,6 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
   void _addAllInDm(QueueList<int> messageIds, DmNarrow dmNarrow) {
     dms.update(dmNarrow,
       ifAbsent: () => messageIds,
-      // setUnion dedupes existing and incoming unread IDs,
-      // so we tolerate zulip/zulip#22164, fixed in 6.0
-      // TODO(server-6) remove 6.0 comment
-      (existing) => setUnion(existing, messageIds),
-    );
+      (existing) => setUnion(existing, messageIds));
   }
 }
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index c01b8b9937..1d41a574d5 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -1013,8 +1013,6 @@ void showMessageActionSheet({required BuildContext context, required Message mes
   final isComposeBoxOffered = messageListPage.composeBoxState != null;

   final isMessageRead = message.flags.contains(MessageFlag.read);
-  final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6)
-  final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;

   final isSenderMuted = store.isUserMuted(message.senderId);

@@ -1029,7 +1027,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
       StarButton(message: message, pageContext: pageContext),
       if (isComposeBoxOffered)
         QuoteAndReplyButton(message: message, pageContext: pageContext),
-      if (showMarkAsUnreadButton)
+      if (isMessageRead)
         MarkAsUnreadButton(message: message, pageContext: pageContext),
       if (isSenderMuted)
         // The message must have been revealed in order to open this action sheet.
@@ -1109,10 +1107,7 @@ bool _getShouldShowEditButton(BuildContext pageContext, Message message) {

   final now = ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000;
   final editLimit = store.realmMessageContentEditLimitSeconds;
-  final outsideEditLimit =
-    editLimit != null
-    && editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit)
-    && now - message.timestamp > editLimit;
+  final outsideEditLimit = editLimit != null && now - message.timestamp > editLimit;

   return message.senderId == store.selfUserId
     && isComposeBoxOffered
diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart
index dd34564768..b75a85ecef 100644
--- a/lib/widgets/actions.dart
+++ b/lib/widgets/actions.dart
@@ -68,7 +68,6 @@ abstract final class ZulipAction {
     Message message,
     Narrow narrow,
   ) async {
-    assert(PerAccountStoreWidget.of(context).zulipFeatureLevel >= 155); // TODO(server-6)
     final zulipLocalizations = ZulipLocalizations.of(context);
     await updateMessageFlagsStartingFromAnchor(
       context: context,
diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart
index b3117e465e..2c7c5d61ab 100644
--- a/lib/widgets/emoji_reaction.dart
+++ b/lib/widgets/emoji_reaction.dart
@@ -130,7 +130,7 @@ class ReactionChipsList extends StatelessWidget {
   Widget build(BuildContext context) {
     final zulipLocalizations = ZulipLocalizations.of(context);
     final store = PerAccountStoreWidget.of(context);
-    final displayEmojiReactionUsers = store.userSettings.displayEmojiReactionUsers ?? false;
+    final displayEmojiReactionUsers = store.userSettings.displayEmojiReactionUsers;
     final showNames = displayEmojiReactionUsers && reactions.total <= 3;

     Widget result = Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,

diff --git a/test/example_data.dart b/test/example_data.dart
index 9bc82b9d6d..a26b94ff13 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -821,14 +821,13 @@ GetMessagesResult nearGetMessagesResult({
 /// A GetMessagesResult the server might return when we request older messages.
 GetMessagesResult olderGetMessagesResult({
   required int anchor,
-  bool foundAnchor = false, // the value if the server understood includeAnchor false
   required bool foundOldest,
   bool historyLimited = false,
   required List<Message> messages,
 }) {
   return GetMessagesResult(
     anchor: anchor,
-    foundAnchor: foundAnchor,
+    foundAnchor: false,
     foundNewest: false, // empirically always this, even when anchor happens to be latest
     foundOldest: foundOldest,
     historyLimited: historyLimited,
@@ -839,14 +838,13 @@ GetMessagesResult olderGetMessagesResult({
 /// A GetMessagesResult the server might return when we request newer messages.
 GetMessagesResult newerGetMessagesResult({
   required int anchor,
-  bool foundAnchor = false, // the value if the server understood includeAnchor false
   required bool foundNewest,
   bool historyLimited = false,
   required List<Message> messages,
 }) {
   return GetMessagesResult(
     anchor: anchor,
-    foundAnchor: foundAnchor,
+    foundAnchor: false,
     foundOldest: false,
     foundNewest: foundNewest,
     historyLimited: historyLimited,
@@ -1334,7 +1332,7 @@ InitialSnapshot initialSnapshot({
       emojiset: Emojiset.google,
       presenceEnabled: true,
     ),
-    userTopics: userTopics,
+    userTopics: userTopics ?? [],
     // no default; allow `null` to simulate servers without this
     realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup,
     realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup,
diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart
index e7e57357d6..e73965dac2 100644
--- a/test/model/channel_test.dart
+++ b/test/model/channel_test.dart
@@ -121,20 +121,6 @@ void main() {
         value: true));
       check(store.subscriptions[stream.streamId]!.isMuted).isTrue();
     });
-
-    test('SubscriptionProperty.inHomeView updates isMuted instead', () async {
-      final store = eg.store(initialSnapshot: eg.initialSnapshot(
-        streams: [stream],
-        subscriptions: [eg.subscription(stream, isMuted: false)],
-      ));
-      check(store.subscriptions[stream.streamId]!.isMuted).isFalse();
-
-      await store.handleEvent(SubscriptionUpdateEvent(id: 1,
-        streamId: stream.streamId,
-        property: SubscriptionProperty.inHomeView,
-        value: false));
-      check(store.subscriptions[stream.streamId]!.isMuted).isTrue();
-    });
   });

   group('topic visibility', () {
diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart
index 9cb4977a32..39f4305ff3 100644
--- a/test/model/message_list_test.dart
+++ b/test/model/message_list_test.dart
@@ -625,40 +625,6 @@ void main() {
       check(connection.takeRequests()).single;
     }));

-    test('fetchOlder handles servers not understanding includeAnchor', () async {
-      await prepare();
-      await prepareMessages(foundOldest: false,
-        messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i)));
-
-      // The old behavior is to include the anchor message regardless of includeAnchor.
-      connection.prepare(json: olderResult(
-        anchor: 1000, foundOldest: false, foundAnchor: true,
-        messages: List.generate(101, (i) => eg.streamMessage(id: 900 + i)),
-      ).toJson());
-      await model.fetchOlder();
-      checkNotified(count: 2);
-      check(model)
-        ..busyFetchingMore.isFalse()
-        ..messages.length.equals(200);
-    });
-
-    test('fetchNewer handles servers not understanding includeAnchor', () async {
-      await prepare(anchor: NumericAnchor(1000));
-      await prepareMessages(foundOldest: true, foundNewest: false,
-        messages: List.generate(101, (i) => eg.streamMessage(id: 1000 + i)));
-
-      // The old behavior is to include the anchor message regardless of includeAnchor.
-      connection.prepare(json: newerResult(
-        anchor: 1100, foundNewest: false, foundAnchor: true,
-        messages: List.generate(101, (i) => eg.streamMessage(id: 1100 + i)),
-      ).toJson());
-      await model.fetchNewer();
-      checkNotified(count: 2);
-      check(model)
-        ..busyFetchingMore.isFalse()
-        ..messages.length.equals(201);
-    });
-
     // TODO(#824): move this test
     test('fetchOlder recent senders track all the messages', () async {
       await prepare();
diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart
index 4952354044..089ef315cb 100644
--- a/test/model/unreads_test.dart
+++ b/test/model/unreads_test.dart
@@ -1250,22 +1250,6 @@ void main() {
         checkMatchesMessages([message1, message2]);
       });

-      // TODO(server-6) remove mention of zulip/zulip#22164, fixed in 6.0
-      test('tolerates event pointing to DM/stream messages that are already unread (zulip/zulip#22164)', () {
-        final message1 = eg.streamMessage(id: 1, flags: []);
-        final message2 = eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser], flags: []);
-
-        prepare();
-        fillWithMessages([message1, message2]);
-
-        model.handleUpdateMessageFlagsEvent(mkEvent([message1, message2]));
-        checkNotifiedOnce();
-
-        message1.flags.remove(MessageFlag.read);
-        message2.flags.remove(MessageFlag.read);
-        checkMatchesMessages([message1, message2]);
-      });
-
       test('tolerates "message details" missing', () {
         final stream = eg.stream();
         const topic = 'a';
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 22a4b06d8e..06e0eab5ca 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -2202,8 +2202,6 @@ void main() {
         }

         testVisibility(true);
-        // TODO(server-6) limit 0 not expected on 6.0+
-        testVisibility(true, limit: 0);
         testVisibility(true, limit: 600);
         testVisibility(true, narrow: ChannelNarrow(1));


channel: Implement modern group-based permission check for can-send-message

Fixes #1862.

Update can-send-message check to use modern group-based permission when available
We have a permission check that controls which channels appear in the share-to-Zulip page, and also whether we offer a compose box in channels.

We should update it to use the modern `can_send_message_group` permission when that's available, instead of relying on the deprecated `channel_post_policy` field.

flutter test test/model/channel_test.dart --reporter expanded 2>&1

diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 94bf662e30..5d86935186 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -680,6 +680,7 @@ class ChannelUpdateEvent extends ChannelEvent {
       case ChannelPropertyName.canAddSubscribersGroup:
       case ChannelPropertyName.canDeleteAnyMessageGroup:
       case ChannelPropertyName.canDeleteOwnMessageGroup:
+      case ChannelPropertyName.canSendMessageGroup:
       case ChannelPropertyName.canSubscribeGroup:
         return GroupSettingValue.fromJson(value);
       case ChannelPropertyName.streamWeeklyTraffic:
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 398802892a..0313976018 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -465,6 +465,7 @@ const _$ChannelPropertyNameEnumMap = {
   ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group',
   ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group',
   ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group',
+  ChannelPropertyName.canSendMessageGroup: 'can_send_message_group',
   ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group',
   ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic',
 };
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index d0a80c2b4c..2bc6d7d487 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -649,12 +649,13 @@ class ZulipStream {
   bool historyPublicToSubscribers;
   int? messageRetentionDays;
   @JsonKey(name: 'stream_post_policy')
-  ChannelPostPolicy channelPostPolicy;
+  ChannelPostPolicy? channelPostPolicy; // TODO(server-10) remove
   // final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore

   GroupSettingValue? canAddSubscribersGroup; // TODO(server-10)
   GroupSettingValue? canDeleteAnyMessageGroup; // TODO(server-11)
   GroupSettingValue? canDeleteOwnMessageGroup; // TODO(server-11)
+  GroupSettingValue? canSendMessageGroup; // TODO(server-10)
   GroupSettingValue? canSubscribeGroup; // TODO(server-10)

   // TODO(server-8): added in FL 199, was previously only on [Subscription] objects
@@ -676,6 +677,7 @@ class ZulipStream {
     required this.canAddSubscribersGroup,
     required this.canDeleteAnyMessageGroup,
     required this.canDeleteOwnMessageGroup,
+    required this.canSendMessageGroup,
     required this.canSubscribeGroup,
     required this.streamWeeklyTraffic,
   });
@@ -698,6 +700,7 @@ class ZulipStream {
       canAddSubscribersGroup: subscription.canAddSubscribersGroup,
       canDeleteAnyMessageGroup: subscription.canDeleteAnyMessageGroup,
       canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup,
+      canSendMessageGroup: subscription.canSendMessageGroup,
       canSubscribeGroup: subscription.canSubscribeGroup,
       streamWeeklyTraffic: subscription.streamWeeklyTraffic,
     );
@@ -733,6 +736,7 @@ enum ChannelPropertyName {
   canAddSubscribersGroup,
   canDeleteAnyMessageGroup,
   canDeleteOwnMessageGroup,
+  canSendMessageGroup,
   canSubscribeGroup,
   streamWeeklyTraffic;

@@ -817,6 +821,7 @@ class Subscription extends ZulipStream {
     required super.canAddSubscribersGroup,
     required super.canDeleteAnyMessageGroup,
     required super.canDeleteOwnMessageGroup,
+    required super.canSendMessageGroup,
     required super.canSubscribeGroup,
     required super.streamWeeklyTraffic,
     required this.desktopNotifications,
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index b21b2ee29e..70316ae1eb 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -247,7 +247,7 @@ ZulipStream _$ZulipStreamFromJson(Map<String, dynamic> json) => ZulipStream(
   isWebPublic: json['is_web_public'] as bool,
   historyPublicToSubscribers: json['history_public_to_subscribers'] as bool,
   messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(),
-  channelPostPolicy: $enumDecode(
+  channelPostPolicy: $enumDecodeNullable(
     _$ChannelPostPolicyEnumMap,
     json['stream_post_policy'],
   ),
@@ -260,6 +260,9 @@ ZulipStream _$ZulipStreamFromJson(Map<String, dynamic> json) => ZulipStream(
   canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_delete_own_message_group']),
+  canSendMessageGroup: json['can_send_message_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_send_message_group']),
   canSubscribeGroup: json['can_subscribe_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_subscribe_group']),
@@ -283,6 +286,7 @@ Map<String, dynamic> _$ZulipStreamToJson(ZulipStream instance) =>
       'can_add_subscribers_group': instance.canAddSubscribersGroup,
       'can_delete_any_message_group': instance.canDeleteAnyMessageGroup,
       'can_delete_own_message_group': instance.canDeleteOwnMessageGroup,
+      'can_send_message_group': instance.canSendMessageGroup,
       'can_subscribe_group': instance.canSubscribeGroup,
       'stream_weekly_traffic': instance.streamWeeklyTraffic,
     };
@@ -307,7 +311,7 @@ Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
   isWebPublic: json['is_web_public'] as bool,
   historyPublicToSubscribers: json['history_public_to_subscribers'] as bool,
   messageRetentionDays: (json['message_retention_days'] as num?)?.toInt(),
-  channelPostPolicy: $enumDecode(
+  channelPostPolicy: $enumDecodeNullable(
     _$ChannelPostPolicyEnumMap,
     json['stream_post_policy'],
   ),
@@ -320,6 +324,9 @@ Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
   canDeleteOwnMessageGroup: json['can_delete_own_message_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_delete_own_message_group']),
+  canSendMessageGroup: json['can_send_message_group'] == null
+      ? null
+      : GroupSettingValue.fromJson(json['can_send_message_group']),
   canSubscribeGroup: json['can_subscribe_group'] == null
       ? null
       : GroupSettingValue.fromJson(json['can_subscribe_group']),
@@ -351,6 +358,7 @@ Map<String, dynamic> _$SubscriptionToJson(Subscription instance) =>
       'can_add_subscribers_group': instance.canAddSubscribersGroup,
       'can_delete_any_message_group': instance.canDeleteAnyMessageGroup,
       'can_delete_own_message_group': instance.canDeleteOwnMessageGroup,
+      'can_send_message_group': instance.canSendMessageGroup,
       'can_subscribe_group': instance.canSubscribeGroup,
       'stream_weekly_traffic': instance.streamWeeklyTraffic,
       'desktop_notifications': instance.desktopNotifications,
@@ -515,6 +523,7 @@ const _$ChannelPropertyNameEnumMap = {
   ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group',
   ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group',
   ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group',
+  ChannelPropertyName.canSendMessageGroup: 'can_send_message_group',
   ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group',
   ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic',
 };
diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index 0403ba0bdf..098f146df1 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -192,17 +192,36 @@ mixin ChannelStore on UserStore {
     required ZulipStream inChannel,
     required DateTime byDate,
   }) {
+    // (selfHasPermissionForGroupSetting isn't equipped to handle the old-server
+    // fallback logic for this specific permission; it's dynamic and depends on
+    // channelPostPolicy, so we do our own null check here.)
+    if (inChannel.canSendMessageGroup != null) {
+      return selfHasPermissionForGroupSetting(inChannel.canSendMessageGroup!,
+               GroupSettingType.stream, 'can_send_message_group');
+    } else if (inChannel.channelPostPolicy != null) {
+      return _selfPassesLegacyChannelPostPolicy(inChannel: inChannel, atDate: byDate);
+    } else {
+      assert(false); // TODO(log)
+      return true;
+    }
+  }
+
+  bool _selfPassesLegacyChannelPostPolicy({
+    required ZulipStream inChannel,
+    required DateTime atDate,
+  }) {
+    assert(inChannel.channelPostPolicy != null);
     final role = selfUser.role;
-    // We let the users with [unknown] role to send the message, then the server
-    // will decide to accept it or not based on its actual role.
-    if (role == UserRole.unknown) return true;

-    switch (inChannel.channelPostPolicy) {
+    // (Could early-return true on [UserRole.unknown],
+    // but pre-333 servers shouldn't be giving us an unknown role.)
+
+    switch (inChannel.channelPostPolicy!) {
       case ChannelPostPolicy.any:             return true;
       case ChannelPostPolicy.fullMembers:     {
         if (!role.isAtLeast(UserRole.member)) return false;
         if (role == UserRole.member) {
-          return selfHasPassedWaitingPeriod(byDate: byDate);
+          return selfHasPassedWaitingPeriod(byDate: atDate);
         }
         return true;
       }
@@ -405,6 +424,8 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore {
             stream.canDeleteAnyMessageGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.canDeleteOwnMessageGroup:
             stream.canDeleteOwnMessageGroup = event.value as GroupSettingValue;
+          case ChannelPropertyName.canSendMessageGroup:
+            stream.canSendMessageGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.canSubscribeGroup:
             stream.canSubscribeGroup = event.value as GroupSettingValue;
           case ChannelPropertyName.streamWeeklyTraffic:

diff --git a/test/example_data.dart b/test/example_data.dart
index 9a9eb1099e..9bc82b9d6d 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -461,9 +461,16 @@ ZulipStream stream({
   GroupSettingValue? canAddSubscribersGroup,
   GroupSettingValue? canDeleteAnyMessageGroup,
   GroupSettingValue? canDeleteOwnMessageGroup,
+  GroupSettingValue? canSendMessageGroup,
   GroupSettingValue? canSubscribeGroup,
   int? streamWeeklyTraffic,
 }) {
+  if (channelPostPolicy == null) {
+    // Set a default for realmCanDeleteOwnMessageGroup, but only if we're
+    // not trying to test legacy behavior with channelPostPolicy.
+    canSendMessageGroup ??= groupSetting(members: [selfUser.userId]);
+  }
+
   _checkPositive(streamId, 'stream ID');
   _checkPositive(firstMessageId, 'message ID');
   var effectiveStreamId = streamId ?? _nextStreamId();
@@ -485,6 +492,7 @@ ZulipStream stream({
     canAddSubscribersGroup: canAddSubscribersGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     canDeleteAnyMessageGroup: canDeleteAnyMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id),
+    canSendMessageGroup: canSendMessageGroup,
     canSubscribeGroup: canSubscribeGroup ?? GroupSettingValueNamed(nobodyGroup.id),
     streamWeeklyTraffic: streamWeeklyTraffic,
   );
@@ -528,6 +536,7 @@ Subscription subscription(
     canAddSubscribersGroup: stream.canAddSubscribersGroup,
     canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup,
     canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup,
+    canSendMessageGroup: stream.canSendMessageGroup,
     canSubscribeGroup: stream.canSubscribeGroup,
     streamWeeklyTraffic: stream.streamWeeklyTraffic,
     desktopNotifications: desktopNotifications ?? false,
@@ -1206,6 +1215,7 @@ ChannelUpdateEvent channelUpdateEvent(
     case ChannelPropertyName.canAddSubscribersGroup:
     case ChannelPropertyName.canDeleteAnyMessageGroup:
     case ChannelPropertyName.canDeleteOwnMessageGroup:
+    case ChannelPropertyName.canSendMessageGroup:
     case ChannelPropertyName.canSubscribeGroup:
       assert(value is GroupSettingValue);
     case ChannelPropertyName.streamWeeklyTraffic:
diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart
index 00dbfb10a2..e7e57357d6 100644
--- a/test/model/channel_test.dart
+++ b/test/model/channel_test.dart
@@ -9,9 +9,12 @@ import 'package:zulip/model/channel.dart';
 import '../api/model/model_checks.dart';
 import '../example_data.dart' as eg;
 import '../stdlib_checks.dart';
+import 'binding.dart';
 import 'test_store.dart';

 void main() {
+  TestZulipBinding.ensureInitialized();
+
   group('Unified stream/sub data', () {
     /// Check that `streams`, `streamsByName`, and `subscriptions` all agree
     /// and point to the same objects where applicable.
@@ -455,34 +458,55 @@ void main() {
     });
   });

-  group('hasPostingPermission', () {
+  group('selfCanSendMessage', () {
+    test('in group', () {
+      addTearDown(testBinding.reset);
+      final now = testBinding.utcNow();
+
+      final canSendMessageGroup = eg.groupSetting(members: [eg.selfUser.userId]);
+      final channel = eg.stream(canSendMessageGroup: canSendMessageGroup);
+      final store = eg.store(
+        initialSnapshot: eg.initialSnapshot(streams: [channel]));
+      check(store.selfCanSendMessage(inChannel: channel, byDate: now))
+        .isTrue();
+    });
+
+    test('not in group', () {
+      addTearDown(testBinding.reset);
+      final now = testBinding.utcNow();
+
+      final canSendMessageGroup = eg.groupSetting(members: []);
+      final channel = eg.stream(canSendMessageGroup: canSendMessageGroup);
+      final store = eg.store(
+        initialSnapshot: eg.initialSnapshot(streams: [channel]));
+      check(store.selfCanSendMessage(inChannel: channel, byDate: now))
+        .isFalse();
+    });
+  });
+
+  group('selfCanSendMessage, legacy', () {
     final testCases = [
-      (ChannelPostPolicy.unknown,        UserRole.unknown,       true),
       (ChannelPostPolicy.unknown,        UserRole.guest,         true),
       (ChannelPostPolicy.unknown,        UserRole.member,        true),
       (ChannelPostPolicy.unknown,        UserRole.moderator,     true),
       (ChannelPostPolicy.unknown,        UserRole.administrator, true),
       (ChannelPostPolicy.unknown,        UserRole.owner,         true),
-      (ChannelPostPolicy.any,            UserRole.unknown,       true),
       (ChannelPostPolicy.any,            UserRole.guest,         true),
       (ChannelPostPolicy.any,            UserRole.member,        true),
       (ChannelPostPolicy.any,            UserRole.moderator,     true),
       (ChannelPostPolicy.any,            UserRole.administrator, true),
       (ChannelPostPolicy.any,            UserRole.owner,         true),
-      (ChannelPostPolicy.fullMembers,    UserRole.unknown,       true),
       (ChannelPostPolicy.fullMembers,    UserRole.guest,         false),
       // The fullMembers/member case gets its own tests further below.
       // (ChannelPostPolicy.fullMembers,    UserRole.member,        /* complicated */),
       (ChannelPostPolicy.fullMembers,    UserRole.moderator,     true),
       (ChannelPostPolicy.fullMembers,    UserRole.administrator, true),
       (ChannelPostPolicy.fullMembers,    UserRole.owner,         true),
-      (ChannelPostPolicy.moderators,     UserRole.unknown,       true),
       (ChannelPostPolicy.moderators,     UserRole.guest,         false),
       (ChannelPostPolicy.moderators,     UserRole.member,        false),
       (ChannelPostPolicy.moderators,     UserRole.moderator,     true),
       (ChannelPostPolicy.moderators,     UserRole.administrator, true),
       (ChannelPostPolicy.moderators,     UserRole.owner,         true),
-      (ChannelPostPolicy.administrators, UserRole.unknown,       true),
       (ChannelPostPolicy.administrators, UserRole.guest,         false),
       (ChannelPostPolicy.administrators, UserRole.member,        false),
       (ChannelPostPolicy.administrators, UserRole.moderator,     false),