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 ## 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** **Feature level 393**
* [`PATCH /messages/{message_id}`](/api/delete-message), * [`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**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # 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 # 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 # 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"], rendered_description=stream_dict["rendered_description"],
stream_id=stream_dict["stream_id"], stream_id=stream_dict["stream_id"],
stream_post_policy=stream_dict["stream_post_policy"], stream_post_policy=stream_dict["stream_post_policy"],
subscriber_count=stream_dict["subscriber_count"],
topics_policy=stream_dict["topics_policy"], topics_policy=stream_dict["topics_policy"],
# Computed fields not present in Stream.API_FIELDS # Computed fields not present in Stream.API_FIELDS
is_announcement_only=stream_dict["is_announcement_only"], is_announcement_only=stream_dict["is_announcement_only"],

View File

@@ -1630,6 +1630,10 @@ def apply_event(
if sub["stream_id"] == event["stream_id"]: if sub["stream_id"] == event["stream_id"]:
sub[event["property"]] = event["value"] sub[event["property"]] = event["value"]
elif event["op"] == "peer_add": 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: if include_subscribers:
stream_ids = set(event["stream_ids"]) stream_ids = set(event["stream_ids"])
user_ids = set(event["user_ids"]) user_ids = set(event["user_ids"])
@@ -1644,6 +1648,7 @@ def apply_event(
subscribers = set(sub["subscribers"]) | user_ids subscribers = set(sub["subscribers"]) | user_ids
sub["subscribers"] = sorted(subscribers) sub["subscribers"] = sorted(subscribers)
elif event["op"] == "peer_remove": elif event["op"] == "peer_remove":
# Note: We don't update subscriber_count here, as with peer_add.
if include_subscribers: if include_subscribers:
stream_ids = set(event["stream_ids"]) stream_ids = set(event["stream_ids"])
user_ids = set(event["user_ids"]) user_ids = set(event["user_ids"])

View File

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

View File

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

View File

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

View File

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

View File

@@ -1367,6 +1367,7 @@ paths:
"can_remove_subscribers_group": 2, "can_remove_subscribers_group": 2,
"can_subscribe_group": 2, "can_subscribe_group": 2,
"stream_weekly_traffic": null, "stream_weekly_traffic": null,
"subscriber_count": 0,
}, },
], ],
"id": 0, "id": 0,
@@ -16231,6 +16232,7 @@ paths:
can_administer_channel_group: {} can_administer_channel_group: {}
can_send_message_group: {} can_send_message_group: {}
can_subscribe_group: {} can_subscribe_group: {}
subscriber_count: {}
stream_weekly_traffic: stream_weekly_traffic:
type: integer type: integer
nullable: true nullable: true
@@ -21470,6 +21472,7 @@ paths:
can_administer_channel_group: {} can_administer_channel_group: {}
can_send_message_group: {} can_send_message_group: {}
can_subscribe_group: {} can_subscribe_group: {}
subscriber_count: {}
stream_weekly_traffic: stream_weekly_traffic:
type: integer type: integer
nullable: true nullable: true
@@ -21515,6 +21518,7 @@ paths:
- can_subscribe_group - can_subscribe_group
- stream_weekly_traffic - stream_weekly_traffic
- is_recently_active - is_recently_active
- subscriber_count
example: example:
{ {
"msg": "", "msg": "",
@@ -21543,6 +21547,7 @@ paths:
"stream_id": 2, "stream_id": 2,
"stream_post_policy": 1, "stream_post_policy": 1,
"stream_weekly_traffic": null, "stream_weekly_traffic": null,
subscriber_count: 20,
}, },
{ {
"can_add_subscribers_group": 9, "can_add_subscribers_group": 9,
@@ -21566,6 +21571,7 @@ paths:
"stream_id": 1, "stream_id": 1,
"stream_post_policy": 1, "stream_post_policy": 1,
"stream_weekly_traffic": null, "stream_weekly_traffic": null,
subscriber_count: 10,
}, },
], ],
} }
@@ -21621,6 +21627,7 @@ paths:
"can_remove_subscribers_group": 2, "can_remove_subscribers_group": 2,
"can_subscribe_group": 2, "can_subscribe_group": 2,
"stream_weekly_traffic": null, "stream_weekly_traffic": null,
subscriber_count: 12,
}, },
} }
"400": "400":
@@ -23939,6 +23946,7 @@ components:
can_administer_channel_group: {} can_administer_channel_group: {}
can_send_message_group: {} can_send_message_group: {}
can_subscribe_group: {} can_subscribe_group: {}
subscriber_count: {}
stream_weekly_traffic: stream_weekly_traffic:
type: integer type: integer
nullable: true nullable: true
@@ -23965,6 +23973,7 @@ components:
- rendered_description - rendered_description
- is_web_public - is_web_public
- stream_post_policy - stream_post_policy
- subscriber_count
- message_retention_days - message_retention_days
- history_public_to_subscribers - history_public_to_subscribers
- first_message_id - first_message_id
@@ -24134,6 +24143,27 @@ components:
$ref: "#/components/schemas/CanSendMessageGroup" $ref: "#/components/schemas/CanSendMessageGroup"
can_subscribe_group: can_subscribe_group:
$ref: "#/components/schemas/CanSubscribeGroup" $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: BasicBot:
allOf: allOf:
- $ref: "#/components/schemas/BasicBotBase" - $ref: "#/components/schemas/BasicBotBase"
@@ -25214,6 +25244,27 @@ components:
channels. Note that some endpoints will never return archived channels. Note that some endpoints will never return archived
channels unless the client declares explicit support for channels unless the client declares explicit support for
them via the `archived_channels` client capability. 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: DefaultChannelGroup:
type: object type: object
description: | description: |

View File

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