streams: Add subscriber_count to page load data.

This commit is contained in:
Evy Kassirer
2025-06-06 20:59:54 -07:00
committed by Tim Abbott
parent e298eddefc
commit 4313648ca5
10 changed files with 91 additions and 1 deletions

View File

@@ -20,6 +20,15 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 11.0
**Feature level 394**
* [`POST /register`](/api/register-queue), [`GET
/events`](/api/get-events), [`GET /streams`](/api/get-streams),
[`GET /streams/{stream_id}`](/api/get-stream-by-id):: Added a new
field `subscriber_count` to Stream and Subscription objects with the
total number of non-deactivated users who are subscribed to the
channel.
**Feature level 393**
* [`PATCH /messages/{message_id}`](/api/delete-message),

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 393
API_FEATURE_LEVEL = 394
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@@ -475,6 +475,7 @@ def send_subscription_add_events(
rendered_description=stream_dict["rendered_description"],
stream_id=stream_dict["stream_id"],
stream_post_policy=stream_dict["stream_post_policy"],
subscriber_count=stream_dict["subscriber_count"],
topics_policy=stream_dict["topics_policy"],
# Computed fields not present in Stream.API_FIELDS
is_announcement_only=stream_dict["is_announcement_only"],

View File

@@ -1630,6 +1630,10 @@ def apply_event(
if sub["stream_id"] == event["stream_id"]:
sub[event["property"]] = event["value"]
elif event["op"] == "peer_add":
# Note: We don't update subscriber_count here, since we
# have no way to know whether the added subscriber is
# already in our count or not. The opposite decision would
# be defensible, but this is less code.
if include_subscribers:
stream_ids = set(event["stream_ids"])
user_ids = set(event["user_ids"])
@@ -1644,6 +1648,7 @@ def apply_event(
subscribers = set(sub["subscribers"]) | user_ids
sub["subscribers"] = sorted(subscribers)
elif event["op"] == "peer_remove":
# Note: We don't update subscriber_count here, as with peer_add.
if include_subscribers:
stream_ids = set(event["stream_ids"])
user_ids = set(event["user_ids"])

View File

@@ -1538,6 +1538,7 @@ def stream_to_dict(
stream_post_policy=stream_post_policy,
is_announcement_only=stream_post_policy == Stream.STREAM_POST_POLICY_ADMINS,
stream_weekly_traffic=stream_weekly_traffic,
subscriber_count=stream.subscriber_count,
topics_policy=StreamTopicsPolicyEnum(stream.topics_policy).name,
)

View File

@@ -144,6 +144,7 @@ def get_web_public_subs(
stream_id=stream_id,
stream_post_policy=stream_post_policy,
stream_weekly_traffic=stream_weekly_traffic,
subscriber_count=stream.subscriber_count,
topics_policy=StreamTopicsPolicyEnum(topics_policy).name,
wildcard_mentions_notify=wildcard_mentions_notify,
)
@@ -219,6 +220,7 @@ def build_stream_api_dict(
stream_id=raw_stream_dict["id"],
stream_post_policy=raw_stream_dict["stream_post_policy"],
stream_weekly_traffic=stream_weekly_traffic,
subscriber_count=raw_stream_dict["subscriber_count"],
topics_policy=raw_stream_dict["topics_policy"],
is_announcement_only=is_announcement_only,
is_recently_active=raw_stream_dict["is_recently_active"],
@@ -251,6 +253,7 @@ def build_stream_dict_for_sub(
stream_id = stream_dict["stream_id"]
stream_post_policy = stream_dict["stream_post_policy"]
stream_weekly_traffic = stream_dict["stream_weekly_traffic"]
subscriber_count = stream_dict["subscriber_count"]
topics_policy = stream_dict["topics_policy"]
is_announcement_only = stream_dict["is_announcement_only"]
is_recently_active = stream_dict["is_recently_active"]
@@ -301,6 +304,7 @@ def build_stream_dict_for_sub(
stream_id=stream_id,
stream_post_policy=stream_post_policy,
stream_weekly_traffic=stream_weekly_traffic,
subscriber_count=subscriber_count,
topics_policy=topics_policy,
wildcard_mentions_notify=wildcard_mentions_notify,
)
@@ -326,6 +330,7 @@ def build_stream_dict_for_never_sub(
rendered_description = raw_stream_dict["rendered_description"]
stream_id = raw_stream_dict["id"]
stream_post_policy = raw_stream_dict["stream_post_policy"]
subscriber_count = raw_stream_dict["subscriber_count"]
topics_policy = raw_stream_dict["topics_policy"]
if recent_traffic is not None:
@@ -378,6 +383,7 @@ def build_stream_dict_for_never_sub(
stream_id=stream_id,
stream_post_policy=stream_post_policy,
stream_weekly_traffic=stream_weekly_traffic,
subscriber_count=subscriber_count,
topics_policy=topics_policy,
)

View File

@@ -178,6 +178,7 @@ class RawStreamDict(TypedDict):
name: str
rendered_description: str
stream_post_policy: int
subscriber_count: int
topics_policy: str
@@ -235,6 +236,7 @@ class SubscriptionStreamDict(TypedDict):
stream_id: int
stream_post_policy: int
stream_weekly_traffic: int | None
subscriber_count: int
subscribers: NotRequired[list[int]]
partial_subscribers: NotRequired[list[int]]
topics_policy: str
@@ -264,6 +266,7 @@ class NeverSubscribedStreamDict(TypedDict):
stream_id: int
stream_post_policy: int
stream_weekly_traffic: int | None
subscriber_count: int
subscribers: NotRequired[list[int]]
partial_subscribers: NotRequired[list[int]]
topics_policy: str
@@ -295,6 +298,7 @@ class DefaultStreamDict(TypedDict):
rendered_description: str
stream_id: int # `stream_id` represents `id` of the `Stream` object in `API_FIELDS`
stream_post_policy: int
subscriber_count: int
topics_policy: str
# Computed fields not specified in `Stream.API_FIELDS`
is_announcement_only: bool

View File

@@ -258,6 +258,7 @@ class Stream(models.Model):
"message_retention_days",
"name",
"rendered_description",
"subscriber_count",
"can_add_subscribers_group_id",
"can_administer_channel_group_id",
"can_send_message_group_id",

View File

@@ -1367,6 +1367,7 @@ paths:
"can_remove_subscribers_group": 2,
"can_subscribe_group": 2,
"stream_weekly_traffic": null,
"subscriber_count": 0,
},
],
"id": 0,
@@ -16231,6 +16232,7 @@ paths:
can_administer_channel_group: {}
can_send_message_group: {}
can_subscribe_group: {}
subscriber_count: {}
stream_weekly_traffic:
type: integer
nullable: true
@@ -21470,6 +21472,7 @@ paths:
can_administer_channel_group: {}
can_send_message_group: {}
can_subscribe_group: {}
subscriber_count: {}
stream_weekly_traffic:
type: integer
nullable: true
@@ -21515,6 +21518,7 @@ paths:
- can_subscribe_group
- stream_weekly_traffic
- is_recently_active
- subscriber_count
example:
{
"msg": "",
@@ -21543,6 +21547,7 @@ paths:
"stream_id": 2,
"stream_post_policy": 1,
"stream_weekly_traffic": null,
subscriber_count: 20,
},
{
"can_add_subscribers_group": 9,
@@ -21566,6 +21571,7 @@ paths:
"stream_id": 1,
"stream_post_policy": 1,
"stream_weekly_traffic": null,
subscriber_count: 10,
},
],
}
@@ -21621,6 +21627,7 @@ paths:
"can_remove_subscribers_group": 2,
"can_subscribe_group": 2,
"stream_weekly_traffic": null,
subscriber_count: 12,
},
}
"400":
@@ -23939,6 +23946,7 @@ components:
can_administer_channel_group: {}
can_send_message_group: {}
can_subscribe_group: {}
subscriber_count: {}
stream_weekly_traffic:
type: integer
nullable: true
@@ -23965,6 +23973,7 @@ components:
- rendered_description
- is_web_public
- stream_post_policy
- subscriber_count
- message_retention_days
- history_public_to_subscribers
- first_message_id
@@ -24134,6 +24143,27 @@ components:
$ref: "#/components/schemas/CanSendMessageGroup"
can_subscribe_group:
$ref: "#/components/schemas/CanSubscribeGroup"
subscriber_count:
type: number
description: |
The total number of non-deactivated users (including bots) who
are subscribed to the channel. Clients are responsible for updating
this value using `peer_add` and `peer_remove` events.
The server's internals cannot guarantee this value is correctly
synced with `peer_add` and `peer_remove` events for the channel. As
a result, if a (rare) race occurs between a change in the channel's
subscribers and fetching this value, it is possible for a client
that is correctly following the events protocol to end up with a
permanently off-by-one error in the channel's subscriber count.
Clients are recommended to fetch full subscriber data for a channel
in contexts where it is important to avoid this risk. The official
web application, for example, uses this field primarily while
waiting to fetch a given channel's full subscriber list from the
server.
**Changes**: New in Zulip 11.0 (feature level 394).
BasicBot:
allOf:
- $ref: "#/components/schemas/BasicBotBase"
@@ -25214,6 +25244,27 @@ components:
channels. Note that some endpoints will never return archived
channels unless the client declares explicit support for
them via the `archived_channels` client capability.
subscriber_count:
type: number
description: |
The total number of non-deactivated users (including bots) who
are subscribed to the channel. Clients are responsible for updating
this value using `peer_add` and `peer_remove` events.
The server's internals cannot guarantee this value is correctly
synced with `peer_add` and `peer_remove` events for the channel. As
a result, if a (rare) race occurs between a change in the channel's
subscribers and fetching this value, it is possible for a client
that is correctly following the events protocol to end up with a
permanently off-by-one error in the channel's subscriber count.
Clients are recommended to fetch full subscriber data for a channel
in contexts where it is important to avoid this risk. The official
web application, for example, uses this field primarily while
waiting to fetch a given channel's full subscriber list from the
server.
**Changes**: New in Zulip 11.0 (feature level 394).
DefaultChannelGroup:
type: object
description: |

View File

@@ -496,13 +496,25 @@ class BaseAction(ZulipTestCase):
for u in state["never_subscribed"]:
if "subscribers" in u:
u["subscribers"].sort()
# this isn't guaranteed to match
del u["subscriber_count"]
if "subscriptions" in state:
for u in state["subscriptions"]:
if "subscribers" in u:
u["subscribers"].sort()
# this isn't guaranteed to match
del u["subscriber_count"]
state["subscriptions"] = {u["name"]: u for u in state["subscriptions"]}
if "unsubscribed" in state:
for u in state["unsubscribed"]:
# this isn't guaranteed to match
del u["subscriber_count"]
state["unsubscribed"] = {u["name"]: u for u in state["unsubscribed"]}
if "streams" in state:
for stream in state["streams"]:
if "subscriber_count" in stream:
# this isn't guaranteed to match
del stream["subscriber_count"]
if "realm_bots" in state:
state["realm_bots"] = {u["email"]: u for u in state["realm_bots"]}
# Since time is different for every call, just fix the value