mirror of https://github.com/zulip/zulip.git
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:
parent
5bd1a85659
commit
48d2783559
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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 |
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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" }}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"])
|
|
@ -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)})
|
|
@ -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")
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Loading…
Reference in New Issue