read_receipts: Add support for displaying read receipts.

Adds an API endpoint for accessing read receipts for other users, as
well as a modal UI for displaying that information.

Enables the previously merged privacy settings UI for managing whether
a user makes read receipts data available to other users.

Documentation is pending, and we'll likely want to link to the
documentation with help_settings_link once it is complete.

Fixes #3618.

Co-authored-by: Tim Abbott <tabbott@zulip.com>
This commit is contained in:
Dinesh 2021-06-17 00:45:47 +05:30 committed by Tim Abbott
parent 5bd1a85659
commit 48d2783559
23 changed files with 590 additions and 3 deletions

View File

@ -1362,7 +1362,7 @@ export function is_my_user_id(user_id) {
return user_id === my_user_id; return user_id === my_user_id;
} }
function compare_by_name(a, b) { export function compare_by_name(a, b) {
return util.strcmp(a.full_name, b.full_name); return util.strcmp(a.full_name, b.full_name);
} }

View File

@ -38,6 +38,7 @@ import * as overlays from "./overlays";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
import * as people from "./people"; import * as people from "./people";
import * as popover_menus from "./popover_menus"; import * as popover_menus from "./popover_menus";
import * as read_receipts from "./read_receipts";
import * as realm_playground from "./realm_playground"; import * as realm_playground from "./realm_playground";
import * as reminder from "./reminder"; import * as reminder from "./reminder";
import * as resize from "./resize"; import * as resize from "./resize";
@ -509,6 +510,9 @@ export function toggle_actions_popover(element, id) {
const should_display_delete_option = const should_display_delete_option =
message_edit.get_deletability(message) && not_spectator; message_edit.get_deletability(message) && not_spectator;
const should_display_read_receipts_option =
page_params.realm_enable_read_receipts && not_spectator;
const args = { const args = {
message_id: message.id, message_id: message.id,
historical: message.historical, historical: message.historical,
@ -523,6 +527,7 @@ export function toggle_actions_popover(element, id) {
conversation_time_uri, conversation_time_uri,
narrowed: narrow_state.active(), narrowed: narrow_state.active(),
should_display_delete_option, should_display_delete_option,
should_display_read_receipts_option,
should_display_reminder_option: feature_flags.reminders_in_message_action_menu, should_display_reminder_option: feature_flags.reminders_in_message_action_menu,
should_display_edit_and_view_source, should_display_edit_and_view_source,
should_display_quote_and_reply, should_display_quote_and_reply,
@ -1235,6 +1240,14 @@ export function register_click_handlers() {
e.preventDefault(); e.preventDefault();
}); });
$("body").on("click", ".view_read_receipts", (e) => {
const message_id = $(e.currentTarget).data("message-id");
hide_actions_popover();
read_receipts.show_user_list(message_id);
e.stopPropagation();
e.preventDefault();
});
$("body").on("click", ".delete_message", (e) => { $("body").on("click", ".delete_message", (e) => {
const message_id = $(e.currentTarget).data("message-id"); const message_id = $(e.currentTarget).data("message-id");
hide_actions_popover(); hide_actions_popover();

View File

@ -0,0 +1,63 @@
import $ from "jquery";
import SimpleBar from "simplebar";
import render_read_receipts from "../templates/read_receipts.hbs";
import render_read_receipts_modal from "../templates/read_receipts_modal.hbs";
import * as channel from "./channel";
import {$t} from "./i18n";
import * as loading from "./loading";
import * as overlays from "./overlays";
import * as people from "./people";
import * as popovers from "./popovers";
import * as ui_report from "./ui_report";
export function show_user_list(message_id) {
$("body").append(render_read_receipts_modal());
overlays.open_modal("read_receipts_modal", {
autoremove: true,
on_show: () => {
loading.make_indicator($("#read_receipts_modal .loading_indicator"));
channel.get({
url: `/json/messages/${message_id}/read_receipts`,
idempotent: true,
success(data) {
const users = data.user_ids.map((id) => people.get_by_user_id(id));
users.sort(people.compare_by_name);
loading.destroy_indicator($("#read_receipts_modal .loading_indicator"));
if (users.length === 0) {
$("#read_receipts_modal .read_receipts_info").text(
$t({defaultMessage: "No one has read this message yet."}),
);
} else {
$("#read_receipts_modal .read_receipts_info").text(
$t(
{
defaultMessage:
"This message has been read by {num_of_people} people:",
},
{num_of_people: users.length},
),
);
$("#read_receipts_modal .modal__container").addClass(
"showing_read_receipts_list",
);
$("#read_receipts_modal .modal__content").append(
render_read_receipts({users}),
);
new SimpleBar($("#read_receipts_modal .modal__content")[0]);
}
},
error(xhr) {
ui_report.error("", xhr, $("#read_receipts_error"));
loading.destroy_indicator($("#read_receipts_modal .loading_indicator"));
},
});
},
on_hide: () => {
// Ensure any user info popovers are closed
popovers.hide_all();
},
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1205,6 +1205,17 @@ body.dark-theme {
color: hsl(0, 0%, 100%); color: hsl(0, 0%, 100%);
} }
} }
#read_receipts_modal #read_receipts_list li {
&:hover {
background-color: hsla(0, 0%, 100%, 0.05);
}
&:active,
&:focus {
background-color: hsla(0, 0%, 100%, 0.1);
}
}
} }
@supports (-moz-appearance: none) { @supports (-moz-appearance: none) {

View File

@ -120,6 +120,7 @@
color: hsl(0, 0%, 100%) !important; color: hsl(0, 0%, 100%) !important;
} }
#read_receipts_error,
#dialog_error { #dialog_error {
margin-bottom: 10px; margin-bottom: 10px;
} }

View File

@ -650,6 +650,78 @@ strong {
display: none; display: none;
} }
#read_receipts_modal {
.modal__container {
width: 360px;
.modal__content {
/* When showing read receipts, we use simplebar
to make the list scrollable. It requires this to
be flex. */
display: flex;
/* Setting a minimum height prevents the loading indicator
appearing/disappearing from resizing the modal in the
common case that one is requesting read receipts for
PMs. */
min-height: 120px;
/* Setting a maximum height is just for aesthetics; the modal looks
weird if its aspect ratio gets too stretched. */
max-height: 480px;
}
}
.modal__header {
padding-bottom: 0;
}
hr {
margin: 10px 0;
}
.modal__content {
padding: 0 24px 8px;
}
.loading_indicator {
margin: auto;
}
#read_receipts_list {
margin-left: 0;
li {
.read_receipts_user_avatar {
display: inline-block;
height: 20px;
width: 20px;
position: relative;
right: 8px;
border-radius: 4px;
}
margin: 2px 0;
list-style-type: none;
overflow-x: hidden;
padding-left: 10px;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
line-height: 26px;
&:hover {
background-color: hsla(0, 0%, 0%, 0.05);
}
&:active,
&:focus {
background-color: hsla(0, 0%, 0%, 0.1);
outline: none;
}
}
}
}
/* .dropdown-menu from v2.3.2 /* .dropdown-menu from v2.3.2
+ https://github.com/zulip/zulip/commit/7a3a3be7e547d3e8f0ed00820835104867f2433d + https://github.com/zulip/zulip/commit/7a3a3be7e547d3e8f0ed00820835104867f2433d
basic idea of this fix is to remove decorations from :hover and apply them only basic idea of this fix is to remove decorations from :hover and apply them only
@ -931,6 +1003,15 @@ td.pointer {
} }
} }
> .view_read_receipts {
font-size: 14px;
height: 16px;
&:hover {
color: hsl(200, 100%, 40%);
}
}
.edit_content { .edit_content {
width: 12px; width: 12px;

View File

@ -79,6 +79,14 @@
</li> </li>
{{/if}} {{/if}}
{{#if should_display_read_receipts_option}}
<li>
<a class="view_read_receipts" data-message-id="{{message_id}}" tabindex="0">
<i class="zulip-icon zulip-icon-readreceipts" aria-label="{{#tr}}View read receipts{{/tr}}"></i> {{t "View read receipts" }}
</a>
</li>
{{/if}}
<li> <li>
<a class="copy_link" data-message-id="{{message_id}}" data-clipboard-text="{{ conversation_time_uri }}" tabindex="0"> <a class="copy_link" data-message-id="{{message_id}}" data-clipboard-text="{{ conversation_time_uri }}" tabindex="0">
<i class="fa fa-link" aria-hidden="true"></i> {{t "Copy link to message" }} <i class="fa fa-link" aria-hidden="true"></i> {{t "Copy link to message" }}

View File

@ -0,0 +1,8 @@
<ul id="read_receipts_list">
{{#each users}}
<li class="view_user_profile" data-user-id="{{user_id}}" tabindex="0" role="button">
<img class="read_receipts_user_avatar" src="{{avatar_url}}" />
<span>{{full_name}}</span>
</li>
{{/each}}
</ul>

View File

@ -0,0 +1,19 @@
<div class="micromodal" id="read_receipts_modal" aria-hidden="true">
<div class="modal__overlay" tabindex="-1" data-micromodal-close>
<div class="modal__container" role="dialog" aria-modal="true" aria-labelledby="read_receipts_modal_label">
<header class="modal__header">
<h1 class="modal__title" id="read_receipts_modal_label">
{{t "Read receipts" }}
</h1>
<button class="modal__close" aria-label="{{t 'Close modal' }}" data-micromodal-close></button>
</header>
<hr/>
<main class="modal__content">
<div class="alert" id="read_receipts_error"></div>
<div class="read_receipts_info">
</div>
<div class="loading_indicator"></div>
</main>
</div>
</div>
</div>

View File

@ -64,6 +64,7 @@
is_checked=settings_object.send_stream_typing_notifications is_checked=settings_object.send_stream_typing_notifications
label=settings_label.send_stream_typing_notifications label=settings_label.send_stream_typing_notifications
}} }}
{{/if}}
{{> settings_checkbox {{> settings_checkbox
setting_name="send_read_receipts" setting_name="send_read_receipts"
is_checked=settings_object.send_read_receipts is_checked=settings_object.send_read_receipts
@ -71,7 +72,6 @@
tooltip_text=send_read_receipts_tooltip tooltip_text=send_read_receipts_tooltip
hide_tooltip=page_params.realm_enable_read_receipts hide_tooltip=page_params.realm_enable_read_receipts
}} }}
{{/if}}
</div> </div>
</div> </div>

View File

@ -22,6 +22,8 @@ format used by the Zulip server that they are interacting with.
**Feature level 137** **Feature level 137**
* [`GET /messages/{message_id}/read_receipts`](/api/get-read-receipts):
Added new endpoint to fetch read receipts for a message.
* [`POST /register`](/api/register-queue), [`GET * [`POST /register`](/api/register-queue), [`GET
/events`](/api/get-events), `PATCH /realm`: Added new /events`](/api/get-events), `PATCH /realm`: Added new
`enable_read_receipts` realm setting. `enable_read_receipts` realm setting.

View File

@ -14,6 +14,7 @@
* [Get a message's edit history](/api/get-message-history) * [Get a message's edit history](/api/get-message-history)
* [Update personal message flags](/api/update-message-flags) * [Update personal message flags](/api/update-message-flags)
* [Mark messages as read in bulk](/api/mark-all-as-read) * [Mark messages as read in bulk](/api/mark-all-as-read)
* [Get a message's read receipts](/api/get-read-receipts)
#### Drafts #### Drafts

View File

@ -128,6 +128,7 @@ EXEMPT_FILES = make_set(
"static/js/poll_widget.js", "static/js/poll_widget.js",
"static/js/popover_menus.js", "static/js/popover_menus.js",
"static/js/popovers.js", "static/js/popovers.js",
"static/js/read_receipts.js",
"static/js/ready.ts", "static/js/ready.ts",
"static/js/realm_icon.js", "static/js/realm_icon.js",
"static/js/realm_logo.js", "static/js/realm_logo.js",

View File

@ -48,4 +48,4 @@ API_FEATURE_LEVEL = 137
# historical commits sharing the same major version, in which case a # historical commits sharing the same major version, in which case a
# minor version bump suffices. # minor version bump suffices.
PROVISION_VERSION = (197, 1) PROVISION_VERSION = (198, 0)

View File

@ -142,6 +142,7 @@ def do_create_realm(
org_type: Optional[int] = None, org_type: Optional[int] = None,
date_created: Optional[datetime.datetime] = None, date_created: Optional[datetime.datetime] = None,
is_demo_organization: bool = False, is_demo_organization: bool = False,
enable_read_receipts: Optional[bool] = None,
enable_spectator_access: Optional[bool] = None, enable_spectator_access: Optional[bool] = None,
) -> Realm: ) -> Realm:
if string_id == settings.SOCIAL_AUTH_SUBDOMAIN: if string_id == settings.SOCIAL_AUTH_SUBDOMAIN:
@ -178,6 +179,19 @@ def do_create_realm(
assert not settings.PRODUCTION assert not settings.PRODUCTION
kwargs["date_created"] = date_created kwargs["date_created"] = date_created
# Generally, closed organizations like companies want read
# receipts, whereas it's unclear what an open organization's
# preferences will be. We enable the setting by default only for
# closed organizations.
if enable_read_receipts is not None:
kwargs["enable_read_receipts"] = enable_read_receipts
else:
# Hacky: The default of invited_required is True, so we need
# to check for None too.
kwargs["enable_read_receipts"] = (
invite_required is None or invite_required is True or emails_restricted_to_domains
)
with transaction.atomic(): with transaction.atomic():
realm = Realm(string_id=string_id, name=name, **kwargs) realm = Realm(string_id=string_id, name=name, **kwargs)
if is_demo_organization: if is_demo_organization:

View File

@ -3246,6 +3246,10 @@ class AbstractUserMessage(models.Model):
def where_unread() -> str: def where_unread() -> str:
return AbstractUserMessage.where_flag_is_absent(getattr(AbstractUserMessage.flags, "read")) return AbstractUserMessage.where_flag_is_absent(getattr(AbstractUserMessage.flags, "read"))
@staticmethod
def where_read() -> str:
return AbstractUserMessage.where_flag_is_present(getattr(AbstractUserMessage.flags, "read"))
@staticmethod @staticmethod
def where_starred() -> str: def where_starred() -> str:
return AbstractUserMessage.where_flag_is_present( return AbstractUserMessage.where_flag_is_present(

View File

@ -1015,6 +1015,15 @@ def remove_reaction(client: Client, message_id: int) -> None:
validate_against_openapi_schema(result, "/messages/{message_id}/reactions", "delete", "200") validate_against_openapi_schema(result, "/messages/{message_id}/reactions", "delete", "200")
@openapi_test_function("/messages/{message_id}/read_receipts:get")
def get_read_receipts(client: Client, message_id: int) -> None:
# {code_example|start}
# Get read receipts for a message
result = client.call_endpoint(f"/messages/{message_id}/read_receipts", method="GET")
# {code_example|end}
validate_against_openapi_schema(result, "/messages/{message_id}/read_receipts", "get", "200")
def test_nonexistent_stream_error(client: Client) -> None: def test_nonexistent_stream_error(client: Client) -> None:
request = { request = {
"type": "stream", "type": "stream",
@ -1498,6 +1507,7 @@ def test_messages(client: Client, nonadmin_client: Client) -> None:
get_messages(client) get_messages(client)
check_messages_match_narrow(client) check_messages_match_narrow(client)
get_message_history(client, message_id) get_message_history(client, message_id)
get_read_receipts(client, message_id)
delete_message(client, message_id) delete_message(client, message_id)
mark_all_as_read(client) mark_all_as_read(client)
mark_stream_as_read(client) mark_stream_as_read(client)

View File

@ -5811,6 +5811,70 @@ paths:
} }
description: | description: |
An example JSON error response for when the emoji code is invalid: An example JSON error response for when the emoji code is invalid:
/messages/{message_id}/read_receipts:
get:
operationId: get-read-receipts
summary: Get the list of IDs of users who have read a message.
tags: ["messages"]
description: |
Returns a list containing the IDs for all users who have
marked the message as read (and whose privacy settings allow
sharing that information).
The list of users IDs will include any bots who have marked
the message as read via the API (providing a way for bots to
indicate whether they have processed a message successfully in
a way that can be easily inspected in a Zulip client). Bots
for which this behavior is not desired may disable the
`send_read_receipts` setting via the API.
It will never contain the message's sender.
**Changes**: New in Zulip 6.0 (feature level 137).
parameters:
- $ref: "#/components/parameters/MessageId"
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- $ref: "#/components/schemas/SuccessDescription"
- additionalProperties: false
properties:
result: {}
msg: {}
user_ids:
type: array
description: |
An array of IDs of users who have marked the target message as
read and whose read status is available to the current user.
The IDs of users who have disabled sending read receipts
(`send_read_receipts=false`) will never appear in the response,
nor will the message's sender. The current user's ID will, if
they have marked the read target message as read.
items:
type: integer
example:
{"msg": "", "result": "success", "user_ids": [3, 7, 9]}
"400":
description: Bad request.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonError"
- example: {"msg": "Invalid message(s)", "result": "error"}
description: |
A typical JSON response when attempting to
access read receipts for a message ID that
either does not exist or is not accessible to
the current user.
/messages/matches_narrow: /messages/matches_narrow:
get: get:
operationId: check-messages-match-narrow operationId: check-messages-match-narrow

View File

@ -0,0 +1,208 @@
import orjson
from zerver.actions.create_user import do_reactivate_user
from zerver.actions.realm_settings import do_set_realm_property
from zerver.actions.user_settings import do_change_user_setting
from zerver.actions.users import do_deactivate_user
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import UserMessage, UserProfile
class TestReadReceipts(ZulipTestCase):
def mark_message_read(self, user: UserProfile, message_id: int) -> None:
result = self.api_post(
user,
"/json/messages/flags",
{"messages": orjson.dumps([message_id]).decode(), "op": "add", "flag": "read"},
)
self.assert_json_success(result)
def test_stream_message(self) -> None:
hamlet = self.example_user("hamlet")
sender = self.example_user("othello")
message_id = self.send_stream_message(sender, "Verona", "read receipts")
self.login("hamlet")
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id not in result.json()["user_ids"])
self.mark_message_read(hamlet, message_id)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id in result.json()["user_ids"])
self.assertTrue(sender.id not in result.json()["user_ids"])
def test_personal_message(self) -> None:
hamlet = self.example_user("hamlet")
sender = self.example_user("othello")
message_id = self.send_personal_message(sender, hamlet)
self.login("hamlet")
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id not in result.json()["user_ids"])
self.mark_message_read(hamlet, message_id)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id in result.json()["user_ids"])
self.assertTrue(sender.id not in result.json()["user_ids"])
def test_huddle_message(self) -> None:
hamlet = self.example_user("hamlet")
sender = self.example_user("othello")
cordelia = self.example_user("cordelia")
message_id = self.send_huddle_message(sender, [hamlet, cordelia])
self.login("hamlet")
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id not in result.json()["user_ids"])
self.assertTrue(sender.id not in result.json()["user_ids"])
self.mark_message_read(hamlet, message_id)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id in result.json()["user_ids"])
self.assertTrue(sender.id not in result.json()["user_ids"])
def test_inaccessible_stream_message(self) -> None:
sender = self.example_user("othello")
private_stream = "private stream"
self.make_stream(stream_name=private_stream, invite_only=True)
self.subscribe(sender, private_stream)
message_id = self.send_stream_message(sender, private_stream, "read receipts")
self.login("hamlet")
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_error(result, "Invalid message(s)")
self.login_user(sender)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
def test_filter_deactivated_users(self) -> None:
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
sender = self.example_user("othello")
message_id = self.send_stream_message(sender, "Verona", "read receipts")
# Mark message as read for hamlet and cordelia.
self.mark_message_read(hamlet, message_id)
self.mark_message_read(cordelia, message_id)
# Login as cordelia and make sure hamlet is in read receipts before deactivation.
self.login("cordelia")
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id in result.json()["user_ids"])
self.assertTrue(cordelia.id in result.json()["user_ids"])
# Deactivate hamlet and verify hamlet is not in read receipts.
do_deactivate_user(hamlet, acting_user=None)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id not in result.json()["user_ids"])
self.assertTrue(cordelia.id in result.json()["user_ids"])
# Reactivate hamlet and verify hamlet appears again in read recipts.
do_reactivate_user(hamlet, acting_user=None)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertTrue(hamlet.id in result.json()["user_ids"])
self.assertTrue(cordelia.id in result.json()["user_ids"])
def test_send_read_receipts_privacy_setting(self) -> None:
hamlet = self.example_user("hamlet")
sender = self.example_user("othello")
cordelia = self.example_user("cordelia")
message_id = self.send_stream_message(sender, "Verona", "read receipts")
self.mark_message_read(hamlet, message_id)
self.mark_message_read(cordelia, message_id)
self.login("aaron")
do_set_realm_property(sender.realm, "enable_read_receipts", False, acting_user=None)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_error(result, "Read receipts are disabled in this organization.")
do_set_realm_property(sender.realm, "enable_read_receipts", True, acting_user=None)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertIn(hamlet.id, result.json()["user_ids"])
self.assertIn(cordelia.id, result.json()["user_ids"])
# Disable read receipts setting; confirm Cordelia no longer appears.
do_change_user_setting(cordelia, "send_read_receipts", False, acting_user=cordelia)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertIn(hamlet.id, result.json()["user_ids"])
self.assertNotIn(cordelia.id, result.json()["user_ids"])
def test_send_read_receipts_privacy_setting_bot(self) -> None:
hamlet = self.example_user("hamlet")
sender = self.example_user("othello")
bot = self.example_user("default_bot")
message_id = self.send_stream_message(sender, "Verona", "read receipts")
self.mark_message_read(hamlet, message_id)
self.mark_message_read(bot, message_id)
self.login("aaron")
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertIn(hamlet.id, result.json()["user_ids"])
self.assertIn(bot.id, result.json()["user_ids"])
# Disable read receipts setting; confirm bot no longer appears.
do_change_user_setting(bot, "send_read_receipts", False, acting_user=bot)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertIn(hamlet.id, result.json()["user_ids"])
self.assertNotIn(bot.id, result.json()["user_ids"])
def test_historical_usermessages_read_flag_not_considered(self) -> None:
"""
Ensure UserMessage rows with historical flag are also
considered for read receipts.
"""
hamlet = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
stream_name = "test stream"
self.subscribe(cordelia, stream_name)
message_id = self.send_stream_message(cordelia, stream_name, content="foo")
self.login("hamlet")
# Have hamlet react to the message to
# create a historical UserMessage row.
reaction_info = {
"emoji_name": "smile",
}
result = self.client_post(f"/json/messages/{message_id}/reactions", reaction_info)
self.assert_json_success(result)
# Ensure UserMessage row with historical and read flags exists
user_message = UserMessage.objects.get(user_profile=hamlet, message_id=message_id)
self.assertTrue(user_message.flags.historical)
self.assertTrue(user_message.flags.read)
result = self.client_get(f"/json/messages/{message_id}/read_receipts")
self.assert_json_success(result)
self.assertIn(hamlet.id, result.json()["user_ids"])

View File

@ -0,0 +1,75 @@
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import gettext as _
from zerver.lib.exceptions import JsonableError
from zerver.lib.message import access_message
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success
from zerver.lib.validator import to_non_negative_int
from zerver.models import UserMessage, UserProfile
@has_request_variables
def read_receipts(
request: HttpRequest,
user_profile: UserProfile,
message_id: int = REQ(converter=to_non_negative_int, path_only=True),
) -> HttpResponse:
message = access_message(user_profile, message_id)[0]
if not user_profile.realm.enable_read_receipts:
raise JsonableError(_("Read receipts are disabled in this organization."))
# This query implements a few decisions:
# * Most importantly, this is where we enforce the
# send_read_receipts privacy setting.
#
# * The message sender is never included, since presumably they
# read the message before sending it, and showing the sender as
# having read their own message is likely to be confusing.
#
# * Deactivated users are excluded. While in theory someone
# could be interested in the information, not including them
# is a cleaner policy, and usually read receipts are only of
# interest shortly after a message was sent.
#
# * We do not filter on the historical flag. This means that a user
# who stars a public stream message that they did not originally
# receive will appear in read receipts for that message.
#
# * Marking a message as unread causes it to appear as not read
# via read receipts, as well. This is consistent with the fact
# that users can view a message without leaving it marked as
# read in other ways (desktop/email/push notifications).
#
# * Bots are included. Most bots never mark any messages as read,
# but one could imagine having them to do so via the API to
# communicate useful information. For example, the `read` flag
# could be used by a bot to track which messages have been
# bridged to another chat system or otherwise processed
# successfully by the bot, and users might find it useful to be
# able to inspect that in the UI. If this behavior is not
# desired for a bot, it can be disabled using the
# send_read_receipts privacy setting.
#
# Note that we do not attempt to present how many users received a
# message but have NOT marked the message as read. There are
# tricky corner cases involved in doing so, such as the
# `historical` flag for public stream messages; but the most
# important one is how to handle users who read a message and then
# later unsubscribed from a stream.
user_ids = (
UserMessage.objects.filter(
message_id=message.id,
user_profile__is_active=True,
user_profile__send_read_receipts=True,
)
.exclude(user_profile_id=message.sender_id)
.extra(
where=[UserMessage.where_read()],
)
.values_list("user_profile_id", flat=True)
)
return json_success(request, {"user_ids": list(user_ids)})

View File

@ -322,6 +322,7 @@ class Command(BaseCommand):
invite_required=False, invite_required=False,
plan_type=Realm.PLAN_TYPE_SELF_HOSTED, plan_type=Realm.PLAN_TYPE_SELF_HOSTED,
org_type=Realm.ORG_TYPES["business"]["id"], org_type=Realm.ORG_TYPES["business"]["id"],
enable_read_receipts=True,
enable_spectator_access=True, enable_spectator_access=True,
) )
RealmDomain.objects.create(realm=zulip_realm, domain="zulip.com") RealmDomain.objects.create(realm=zulip_realm, domain="zulip.com")

View File

@ -98,6 +98,7 @@ from zerver.views.push_notifications import (
remove_apns_device_token, remove_apns_device_token,
) )
from zerver.views.reactions import add_reaction, remove_reaction from zerver.views.reactions import add_reaction, remove_reaction
from zerver.views.read_receipts import read_receipts
from zerver.views.realm import ( from zerver.views.realm import (
check_subdomain_available, check_subdomain_available,
deactivate_realm, deactivate_realm,
@ -344,6 +345,8 @@ v1_api_and_json_patterns = [
# POST adds a reaction to a message # POST adds a reaction to a message
# DELETE removes a reaction from a message # DELETE removes a reaction from a message
rest_path("messages/<int:message_id>/reactions", POST=add_reaction, DELETE=remove_reaction), rest_path("messages/<int:message_id>/reactions", POST=add_reaction, DELETE=remove_reaction),
# read_receipts -> zerver.views.read_receipts
rest_path("messages/<int:message_id>/read_receipts", GET=read_receipts),
# attachments -> zerver.views.attachments # attachments -> zerver.views.attachments
rest_path("attachments", GET=list_by_user), rest_path("attachments", GET=list_by_user),
rest_path("attachments/<int:attachment_id>", DELETE=remove), rest_path("attachments/<int:attachment_id>", DELETE=remove),