narrow: Implement rendering of `with` narrow operators.

Adds server and web app support for processing the new `with`
search operator.

Fixes part of #21505.

Co-authored-by: roanster007 <rohan.gudimetla07@gmail.com>
Co-authored-by: Tim Abbott <tabbott@zulip.com>
This commit is contained in:
Aman Agrawal 2024-06-28 06:57:02 +00:00 committed by Tim Abbott
parent bfd54e27b1
commit 70be9e8c51
14 changed files with 658 additions and 22 deletions

View File

@ -20,6 +20,16 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 9.0 ## Changes in Zulip 9.0
**Feature level 271**
* [`GET /messages`](/api/get-messages),
[`GET /messages/matches_narrow`](/api/check-messages-match-narrow),
[`POST /messages/flags/narrow`](/api/update-message-flags-for-narrow),
[`POST /register`](/api/register-queue):
Added support for a new [search/narrow filter](/api/construct-narrow),
`with`, which returns messages in the same channel and topic as that
of the operand of filter, with the first unread message as anchor.
**Feature level 270** **Feature level 270**
* `PATCH /realm`, [`POST /register`](/api/register-queue), * `PATCH /realm`, [`POST /register`](/api/register-queue),

View File

@ -51,7 +51,10 @@ important optimization when fetching messages in certain cases (e.g.,
when [adding the `read` flag to a user's personal when [adding the `read` flag to a user's personal
messages](/api/update-message-flags-for-narrow)). messages](/api/update-message-flags-for-narrow)).
**Changes**: In Zulip 9.0 (feature level 265), support was added for a **Changes**: In Zulip 9.0 (feature level 271), narrows gained support
for a new `with` operator for linking to topics.
In Zulip 9.0 (feature level 265), support was added for a
new `is:followed` filter, matching messages in topics that the current new `is:followed` filter, matching messages in topics that the current
user is [following](/help/follow-a-topic). user is [following](/help/follow-a-topic).
@ -88,15 +91,24 @@ filters did.
### Message IDs ### Message IDs
The `near` and `id` operators, documented in the help center, use message The `with` operator is designed to be used in links that should follow
IDs for their operands. a topic across being moved. Its operand is a message ID.
The `near` and `id` operators, documented in the help center,
also use message IDs for their operands.
* `with:12345`: Search for the conversation (stream/topic pair) which
contains the message with ID `12345`. If such a message exists, and
can be accessed by the user, then the search will be treated as having
the `channel`/`topic`/`dm` operators corresponding to the
conversation containing that message, replacing any such operators
in the original request.
* `near:12345`: Search messages around the message with ID `12345`. * `near:12345`: Search messages around the message with ID `12345`.
* `id:12345`: Search for only message with ID `12345`. * `id:12345`: Search for only message with ID `12345`.
The message ID operand for the `id` operator may be encoded as either a The message ID operand for the `with` and `id` operators may be encoded
number or a string. The message ID operand for the `near` operator must as either a number or a string. The message ID operand for the `near`
be encoded as a string. operator must be encoded as a string.
**Changes**: Prior to Zulip 8.0 (feature level 194), the message ID **Changes**: Prior to Zulip 8.0 (feature level 194), the message ID
operand for the `id` operator needed to be encoded as a string. operand for the `id` operator needed to be encoded as a string.

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 = 270 # Last bumped for direct_message_permission_group API_FEATURE_LEVEL = 271 # Last bumped for `with` operator.
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision

View File

@ -295,12 +295,12 @@ export class Filter {
_sorted_term_types?: string[] = undefined; _sorted_term_types?: string[] = undefined;
_predicate?: (message: Message) => boolean; _predicate?: (message: Message) => boolean;
_can_mark_messages_read?: boolean; _can_mark_messages_read?: boolean;
requires_adjustment_for_moved_with_target?: boolean;
constructor(terms: NarrowTerm[]) { constructor(terms: NarrowTerm[]) {
this._terms = this.fix_terms(terms); this._terms = terms;
if (this.has_operator("channel")) { this.setup_filter(terms);
this._sub = stream_data.get_sub_by_name(this.operands("channel")[0]!); this.requires_adjustment_for_moved_with_target = this.has_operator("with");
}
} }
static canonicalize_operator(operator: string): string { static canonicalize_operator(operator: string): string {
@ -389,6 +389,38 @@ export class Filter {
}; };
} }
static ensure_channel_topic_terms(orig_terms: NarrowTerm[]): NarrowTerm[] {
// In presence of `with` term without channel or topic terms in the narrow, the
// narrow is populated with the channel and toipic terms through this operation,
// so that `with` can be used as a standalone operator to target conversation.
const with_term = orig_terms.find((term: NarrowTerm) => term.operator === "with");
if (!with_term) {
return orig_terms;
}
const updated_terms = [...orig_terms];
let channel_term = updated_terms.find(
(term: NarrowTerm) => Filter.canonicalize_operator(term.operator) === "channel",
);
let topic_term = updated_terms.find(
(term: NarrowTerm) => Filter.canonicalize_operator(term.operator) === "topic",
);
if (!topic_term) {
topic_term = {operator: "topic", operand: ""};
const with_index = updated_terms.indexOf(with_term);
updated_terms.splice(with_index, 0, topic_term);
}
if (!channel_term) {
channel_term = {operator: "channel", operand: ""};
const topic_index = updated_terms.indexOf(topic_term);
updated_terms.splice(topic_index, 0, channel_term);
}
return updated_terms;
}
/* We use a variant of URI encoding which looks reasonably /* We use a variant of URI encoding which looks reasonably
nice and still handles unambiguously cases such as nice and still handles unambiguously cases such as
spaces in operands. spaces in operands.
@ -576,6 +608,7 @@ export class Filter {
"channels-public", "channels-public",
"channel", "channel",
"topic", "topic",
"with",
"dm", "dm",
"dm-including", "dm-including",
"sender", "sender",
@ -775,6 +808,11 @@ export class Filter {
static adjusted_terms_if_moved(raw_terms: NarrowTerm[], message: Message): NarrowTerm[] | null { static adjusted_terms_if_moved(raw_terms: NarrowTerm[], message: Message): NarrowTerm[] | null {
if (message.type !== "stream") { if (message.type !== "stream") {
// BUG: We should be replacing the channel/topic/dm
// operators with the singular dm operator for the
// conversation in question. This isn't very important,
// because direct messages cannot be moved, but it should
// be fixed, since it is triggerable incorrect behavior.
return null; return null;
} }
@ -784,6 +822,8 @@ export class Filter {
const adjusted_terms = []; const adjusted_terms = [];
let terms_changed = false; let terms_changed = false;
raw_terms = Filter.ensure_channel_topic_terms(raw_terms);
for (const term of raw_terms) { for (const term of raw_terms) {
const adjusted_term = {...term}; const adjusted_term = {...term};
if ( if (
@ -812,6 +852,13 @@ export class Filter {
return adjusted_terms; return adjusted_terms;
} }
setup_filter(terms: NarrowTerm[]): void {
this._terms = this.fix_terms(terms);
if (this.has_operator("channel")) {
this._sub = stream_data.get_sub_by_name(this.operands("channel")[0]!);
}
}
equals(filter: Filter, excluded_operators?: string[]): boolean { equals(filter: Filter, excluded_operators?: string[]): boolean {
return _.isEqual( return _.isEqual(
filter.sorted_terms(excluded_operators), filter.sorted_terms(excluded_operators),
@ -929,6 +976,7 @@ export class Filter {
"channels-web-public", "channels-web-public",
"not-channels-web-public", "not-channels-web-public",
"near", "near",
"with",
]); ]);
for (const term of term_types) { for (const term of term_types) {
@ -963,6 +1011,10 @@ export class Filter {
// it is limited by the user's message history. Therefore, we check "channel" // it is limited by the user's message history. Therefore, we check "channel"
// and "topic" together to ensure that the current filter will return all the // and "topic" together to ensure that the current filter will return all the
// messages of a conversation. // messages of a conversation.
if (_.isEqual(term_types, ["channel", "topic", "with"])) {
return true;
}
if (_.isEqual(term_types, ["channel", "topic"])) { if (_.isEqual(term_types, ["channel", "topic"])) {
return true; return true;
} }
@ -1180,6 +1232,7 @@ export class Filter {
const term_types = this.sorted_term_types(); const term_types = this.sorted_term_types();
if ( if (
(term_types.length === 3 && _.isEqual(term_types, ["channel", "topic", "near"])) || (term_types.length === 3 && _.isEqual(term_types, ["channel", "topic", "near"])) ||
(term_types.length === 3 && _.isEqual(term_types, ["channel", "topic", "with"])) ||
(term_types.length === 2 && _.isEqual(term_types, ["channel", "topic"])) || (term_types.length === 2 && _.isEqual(term_types, ["channel", "topic"])) ||
(term_types.length === 1 && _.isEqual(term_types, ["channel"])) (term_types.length === 1 && _.isEqual(term_types, ["channel"]))
) { ) {
@ -1362,10 +1415,12 @@ export class Filter {
fix_terms(terms: NarrowTerm[]): NarrowTerm[] { fix_terms(terms: NarrowTerm[]): NarrowTerm[] {
terms = this._canonicalize_terms(terms); terms = this._canonicalize_terms(terms);
terms = this._fix_redundant_is_private(terms); terms = this._fix_redundant_is_private(terms);
terms = this._fix_redundant_with_dm(terms);
return terms; return terms;
} }
_fix_redundant_is_private(terms: NarrowTerm[]): NarrowTerm[] { _fix_redundant_is_private(terms: NarrowTerm[]): NarrowTerm[] {
// Every DM is a DM, so drop `is:dm` if on a DM conversation.
if (!terms.some((term) => Filter.term_type(term) === "dm")) { if (!terms.some((term) => Filter.term_type(term) === "dm")) {
return terms; return terms;
} }
@ -1373,6 +1428,16 @@ export class Filter {
return terms.filter((term) => Filter.term_type(term) !== "is-dm"); return terms.filter((term) => Filter.term_type(term) !== "is-dm");
} }
_fix_redundant_with_dm(terms: NarrowTerm[]): NarrowTerm[] {
// Because DMs can't move, the `with` operator is a noop on a
// DM conversation.
if (terms.some((term) => Filter.term_type(term) === "dm")) {
return terms.filter((term) => Filter.term_type(term) !== "with");
}
return terms;
}
_canonicalize_terms(terms_mixed_case: NarrowTerm[]): NarrowTerm[] { _canonicalize_terms(terms_mixed_case: NarrowTerm[]): NarrowTerm[] {
return terms_mixed_case.map((term: NarrowTerm) => Filter.canonicalize_term(term)); return terms_mixed_case.map((term: NarrowTerm) => Filter.canonicalize_term(term));
} }
@ -1512,4 +1577,27 @@ export class Filter {
!this.has_operand("is", "starred") !this.has_operand("is", "starred")
); );
} }
try_adjusting_for_moved_with_target(message?: Message): void {
// If we have the message named in a `with` operator
// available, either via parameter or message_store,
if (!this.requires_adjustment_for_moved_with_target) {
return;
}
if (!message) {
const message_id = Number.parseInt(this.operands("with")[0]!, 10);
message = message_store.get(message_id);
}
if (!message) {
return;
}
const adjusted_terms = Filter.adjusted_terms_if_moved(this._terms, message);
if (adjusted_terms) {
this.setup_filter(adjusted_terms);
}
this.requires_adjustment_for_moved_with_target = false;
}
} }

View File

@ -127,6 +127,7 @@ export const allowed_web_public_narrows = [
"search", "search",
"near", "near",
"id", "id",
"with",
]; ];
export function is_spectator_compatible(hash: string): boolean { export function is_spectator_compatible(hash: string): boolean {

View File

@ -80,6 +80,9 @@ function process_result(data, opts) {
if (messages.length !== 0) { if (messages.length !== 0) {
if (opts.msg_list) { if (opts.msg_list) {
if (opts.validate_filter_topic_post_fetch) {
opts.msg_list.data.filter.try_adjusting_for_moved_with_target(messages[0]);
}
// Since this adds messages to the MessageList and renders MessageListView, // Since this adds messages to the MessageList and renders MessageListView,
// we don't need to call it if msg_list was not defined by the caller. // we don't need to call it if msg_list was not defined by the caller.
message_util.add_old_messages(messages, opts.msg_list); message_util.add_old_messages(messages, opts.msg_list);
@ -394,6 +397,7 @@ export function load_messages_for_narrow(opts) {
num_after: consts.narrow_after, num_after: consts.narrow_after,
msg_list: opts.msg_list, msg_list: opts.msg_list,
cont: opts.cont, cont: opts.cont,
validate_filter_topic_post_fetch: opts.validate_filter_topic_post_fetch,
}); });
} }

View File

@ -87,7 +87,19 @@ export function changehash(newhash, trigger) {
return; return;
} }
message_viewport.stop_auto_scrolling(); message_viewport.stop_auto_scrolling();
browser_history.set_hash(newhash);
if (trigger === "retarget topic location") {
// It is important to use `replaceState` rather than `replace`
// here for the `back` button to work; we don't want to use
// any metadata potentially stored by
// update_current_history_state_data associated with an old
// URL for the target conversation, and conceptually we want
// to replace the inaccurate/old URL for the conversation with
// the current/corrected value.
window.history.replaceState(null, "", newhash);
} else {
browser_history.set_hash(newhash);
}
} }
export function update_hash_to_match_filter(filter, trigger) { export function update_hash_to_match_filter(filter, trigger) {
@ -132,12 +144,14 @@ function create_and_update_message_list(filter, id_info, opts) {
}); });
// Populate the message list if we can apply our filter locally (i.e. // Populate the message list if we can apply our filter locally (i.e.
// with no backend help) and we have the message we want to select. // with no server help) and we have the message we want to select.
// Also update id_info accordingly. // Also update id_info accordingly.
maybe_add_local_messages({ if (!filter.requires_adjustment_for_moved_with_target) {
id_info, maybe_add_local_messages({
msg_data, id_info,
}); msg_data,
});
}
if (!id_info.local_select_id) { if (!id_info.local_select_id) {
// If we're not actually ready to select an ID, we need to // If we're not actually ready to select an ID, we need to
@ -306,6 +320,7 @@ export function show(raw_terms, opts) {
raw_terms = [{operator: "is", operand: "home"}]; raw_terms = [{operator: "is", operand: "home"}];
} }
const filter = new Filter(raw_terms); const filter = new Filter(raw_terms);
filter.try_adjusting_for_moved_with_target();
if (try_rendering_locally_for_same_narrow(filter, opts)) { if (try_rendering_locally_for_same_narrow(filter, opts)) {
return; return;
@ -642,7 +657,18 @@ export function show(raw_terms, opts) {
} }
message_fetch.load_messages_for_narrow({ message_fetch.load_messages_for_narrow({
anchor, anchor,
validate_filter_topic_post_fetch:
filter.requires_adjustment_for_moved_with_target,
cont() { cont() {
if (
!filter.requires_adjustment_for_moved_with_target &&
filter.has_operator("with")
) {
// We've already adjusted our filter via
// filter.try_adjusting_for_moved_with_target, and
// should update the URL hash accordingly.
update_hash_to_match_filter(filter, "retarget topic location");
}
if (!select_immediately) { if (!select_immediately) {
render_message_list_with_selected_message({ render_message_list_with_selected_message({
id_info, id_info,

View File

@ -1,6 +1,9 @@
import assert from "minimalistic-assert";
import * as blueslip from "./blueslip"; import * as blueslip from "./blueslip";
import {Filter} from "./filter"; import {Filter} from "./filter";
import * as message_lists from "./message_lists"; import * as message_lists from "./message_lists";
import * as message_store from "./message_store";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
import * as people from "./people"; import * as people from "./people";
import type {NarrowTerm} from "./state_data"; import type {NarrowTerm} from "./state_data";
@ -265,6 +268,42 @@ export function _possible_unread_message_ids(
let topic_name; let topic_name;
let current_filter_pm_string; let current_filter_pm_string;
// For the `with` operator, we can only correctly compute the
// correct channel/topic for lookup unreads in if we either
// have the message in our local cache, or we know the filter
// has already been updated for potentially moved messages.
//
// The code path that needs this function is never called in
// the `with` code path, but for safety, we assert that
// assumption is not violated.
//
// If we need to change that assumption, we can try looking up the
// target message in message_store, but would need to return
// undefined if the target message is not available.
assert(!current_filter.requires_adjustment_for_moved_with_target);
if (current_filter.can_bucket_by("channel", "topic", "with")) {
sub = stream_sub(current_filter);
topic_name = topic(current_filter);
const with_operand = current_filter.operands("with")[0]!;
const target_id = Number.parseInt(with_operand, 10);
const target_message = message_store.get(target_id)!;
if (target_message?.type === "private") {
// BUG: In theory, the fact that we've asserted
// !current_filter.requires_adjustment_for_moved_with_target
// should mean this is not possible; but
// filter.adjusted_terms_if_moved incorrectly does not
// ensure this. Once that bug is fixed, we can delete this case.
return [];
}
if (sub === undefined || topic_name === undefined) {
return [];
}
return unread.get_msg_ids_for_topic(sub.stream_id, topic_name);
}
if (current_filter.can_bucket_by("channel", "topic")) { if (current_filter.can_bucket_by("channel", "topic")) {
sub = stream_sub(current_filter); sub = stream_sub(current_filter);
topic_name = topic(current_filter); topic_name = topic(current_filter);

View File

@ -397,6 +397,25 @@ test("basics", () => {
assert.ok(filter.is_conversation_view()); assert.ok(filter.is_conversation_view());
assert.ok(!filter.is_conversation_view_with_near()); assert.ok(!filter.is_conversation_view_with_near());
terms = [
{operator: "channel", operand: "foo"},
{operator: "topic", operand: "bar"},
{operator: "with", operand: 17},
];
filter = new Filter(terms);
assert.ok(!filter.is_keyword_search());
assert.ok(filter.can_mark_messages_read());
assert.ok(filter.supports_collapsing_recipients());
assert.ok(!filter.contains_only_private_messages());
assert.ok(filter.allow_use_first_unread_when_narrowing());
assert.ok(filter.includes_full_stream_history());
assert.ok(filter.can_apply_locally());
assert.ok(!filter.is_personal_filter());
assert.ok(!filter.is_conversation_view());
assert.ok(filter.can_bucket_by("channel", "topic", "with"));
assert.ok(!filter.is_conversation_view_with_near());
// "stream" was renamed to "channel" // "stream" was renamed to "channel"
terms = [ terms = [
{operator: "stream", operand: "foo"}, {operator: "stream", operand: "foo"},
@ -723,6 +742,13 @@ test("redundancies", () => {
]; ];
filter = new Filter(terms); filter = new Filter(terms);
assert.ok(filter.can_bucket_by("is-dm", "not-dm")); assert.ok(filter.can_bucket_by("is-dm", "not-dm"));
terms = [
{operator: "dm", operand: "joe@example.com,"},
{operator: "with", operand: "12"},
];
filter = new Filter(terms);
assert.ok(filter.can_bucket_by("dm"));
}); });
test("canonicalization", () => { test("canonicalization", () => {
@ -787,6 +813,23 @@ test("canonicalization", () => {
assert.equal(term.operand, "reaction"); assert.equal(term.operand, "reaction");
}); });
test("ensure_channel_topic_terms", () => {
const channel_term = {operator: "channel", operand: ""};
const topic_term = {operator: "topic", operand: ""};
const term_1 = Filter.ensure_channel_topic_terms([{operator: "with", operand: 12}]);
const term_2 = Filter.ensure_channel_topic_terms([topic_term, {operator: "with", operand: 12}]);
const term_3 = Filter.ensure_channel_topic_terms([
channel_term,
{operator: "with", operand: 12},
]);
const terms = [term_1, term_2, term_3];
for (const term of terms) {
assert.deepEqual(term, [channel_term, topic_term, {operator: "with", operand: 12}]);
}
});
test("predicate_basics", ({override}) => { test("predicate_basics", ({override}) => {
// Predicates are functions that accept a message object with the message // Predicates are functions that accept a message object with the message
// attributes (not content), and return true if the message belongs in a // attributes (not content), and return true if the message belongs in a
@ -1598,6 +1641,8 @@ test("term_type", () => {
["dm", "near", "is-unread", "has-link"], ["dm", "near", "is-unread", "has-link"],
); );
assert_term_sort(["topic", "channel", "with"], ["channel", "topic", "with"]);
assert_term_sort(["bogus", "channel", "topic"], ["channel", "topic", "bogus"]); assert_term_sort(["bogus", "channel", "topic"], ["channel", "topic", "bogus"]);
assert_term_sort(["channel", "topic", "channel"], ["channel", "channel", "topic"]); assert_term_sort(["channel", "topic", "channel"], ["channel", "channel", "topic"]);
@ -1713,6 +1758,69 @@ test("update_email", () => {
assert.deepEqual(filter.operands("channel"), ["steve@foo.com"]); assert.deepEqual(filter.operands("channel"), ["steve@foo.com"]);
}); });
test("try_adjusting_for_moved_with_target", ({override}) => {
const messages = {
12: {type: "stream", display_recipient: "Scotland", topic: "Test 1", id: 12},
17: {type: "stream", display_recipient: "Verona", topic: "Test 2", id: 17},
2: {type: "direct", id: 2},
};
override(message_store, "get", (msg_id) => messages[msg_id]);
// When the narrow terms are correct, it returns the same terms
let terms = [
{operator: "channel", operand: "Scotland", negated: false},
{operator: "topic", operand: "Test 1", negated: false},
{operator: "with", operand: "12", negated: false},
];
let filter = new Filter(terms);
assert.deepEqual(filter.requires_adjustment_for_moved_with_target, true);
filter.try_adjusting_for_moved_with_target();
assert.deepEqual(filter.requires_adjustment_for_moved_with_target, false);
assert.deepEqual(filter.terms(), terms);
// When the narrow terms are incorrect, the narrow is corrected
// to the narrow of the `with` operand.
const incorrect_terms = [
{operator: "channel", operand: "Verona", negated: false},
{operator: "topic", operand: "Test 2", negated: false},
{operator: "with", operand: "12", negated: false},
];
filter = new Filter(incorrect_terms);
assert.deepEqual(filter.requires_adjustment_for_moved_with_target, true);
filter.try_adjusting_for_moved_with_target();
assert.deepEqual(filter.requires_adjustment_for_moved_with_target, false);
assert.deepEqual(filter.terms(), terms);
// when message specified in `with` operator does not exist in
// message_store, we rather go to the server, without any updates.
terms = [
{operator: "channel", operand: "Scotland", negated: false},
{operator: "topic", operand: "Test 1", negated: false},
{operator: "with", operand: "11", negated: false},
];
filter = new Filter(terms);
filter.try_adjusting_for_moved_with_target();
assert.deepEqual(filter.requires_adjustment_for_moved_with_target, true);
assert.deepEqual(filter.terms(), terms);
// Since only "stream" recipient type can be moved, if the message
// is of any type other than "stream", same narrow terms are
// returned without the `with` operator.
terms = [
{operator: "channel", operand: "Scotland", negated: false},
{operator: "topic", operand: "Test 1", negated: false},
{operator: "with", operand: "2", negated: false},
];
filter = new Filter(terms);
filter.try_adjusting_for_moved_with_target();
assert.deepEqual(filter.requires_adjustment_for_moved_with_target, false);
assert.deepEqual(filter.terms(), terms);
});
function make_private_sub(name, stream_id) { function make_private_sub(name, stream_id) {
const sub = { const sub = {
name, name,
@ -1732,6 +1840,12 @@ function make_web_public_sub(name, stream_id) {
} }
test("navbar_helpers", () => { test("navbar_helpers", () => {
const sub = {
name: "Foo",
stream_id: 12,
};
stream_data.add_sub(sub);
const stream_id = 43; const stream_id = 43;
make_sub("Foo", stream_id); make_sub("Foo", stream_id);
@ -1841,6 +1955,11 @@ test("navbar_helpers", () => {
{operator: "dm", operand: "joe@example.com"}, {operator: "dm", operand: "joe@example.com"},
{operator: "near", operand: "12"}, {operator: "near", operand: "12"},
]; ];
const channel_with = [
{operator: "channel", operand: "foo"},
{operator: "topic", operand: "bar"},
{operator: "with", operand: "12"},
];
const test_cases = [ const test_cases = [
{ {
@ -2039,6 +2158,13 @@ test("navbar_helpers", () => {
title: properly_separated_names([joe.full_name]), title: properly_separated_names([joe.full_name]),
redirect_url_with_search: "#", redirect_url_with_search: "#",
}, },
{
terms: channel_with,
is_common_narrow: true,
zulip_icon: "hashtag",
title: "Foo",
redirect_url_with_search: "#",
},
]; ];
realm.realm_enable_guest_user_indicator = true; realm.realm_enable_guest_user_indicator = true;

View File

@ -206,6 +206,7 @@ run_test("basics", ({override}) => {
cont: opts.cont, cont: opts.cont,
msg_list: opts.msg_list, msg_list: opts.msg_list,
anchor: 1000, anchor: 1000,
validate_filter_topic_post_fetch: false,
}); });
opts.cont(); opts.cont();

View File

@ -30,6 +30,7 @@ people.add_active_user(alice);
function set_filter(terms) { function set_filter(terms) {
const filter = new Filter(terms); const filter = new Filter(terms);
filter.try_adjusting_for_moved_with_target();
message_lists.set_current({ message_lists.set_current({
data: { data: {
filter, filter,
@ -64,6 +65,7 @@ run_test("get_unread_ids", () => {
id: 101, id: 101,
type: "stream", type: "stream",
stream_id: sub.stream_id, stream_id: sub.stream_id,
display_recipient: sub.name,
topic: "my topic", topic: "my topic",
unread: true, unread: true,
mentioned: true, mentioned: true,
@ -77,8 +79,20 @@ run_test("get_unread_ids", () => {
display_recipient: [{id: alice.user_id}], display_recipient: [{id: alice.user_id}],
}; };
const other_topic_message = {
id: 103,
type: "stream",
stream_id: sub.stream_id,
display_recipient: sub.name,
topic: "another topic",
unread: true,
mentioned: false,
mentioned_me_directly: false,
};
message_store.update_message_cache(stream_msg); message_store.update_message_cache(stream_msg);
message_store.update_message_cache(private_msg); message_store.update_message_cache(private_msg);
message_store.update_message_cache(other_topic_message);
stream_data.add_sub(sub); stream_data.add_sub(sub);
@ -202,6 +216,41 @@ run_test("get_unread_ids", () => {
flavor: "cannot_compute", flavor: "cannot_compute",
}); });
// For a search using `with` operator, our candidate ids
// will be the messages present in the channel/topic
// containing the message for which the `with` operand
// is id to.
//
// Here we use an empty topic for the operators, and show that
// adding the with operator causes us to see unreads in the
// destination topic.
unread.process_loaded_messages([other_topic_message]);
terms = [
{operator: "channel", operand: sub.name},
{operator: "topic", operand: "another topic"},
];
set_filter(terms);
unread_ids = candidate_ids();
assert.deepEqual(unread_ids, [other_topic_message.id]);
terms = [
{operator: "channel", operand: sub.name},
{operator: "topic", operand: "another topic"},
{operator: "with", operand: stream_msg.id},
];
set_filter(terms);
unread_ids = candidate_ids();
assert.deepEqual(unread_ids, [stream_msg.id]);
terms = [
{operator: "channel", operand: sub.name},
{operator: "topic", operand: "another topic"},
{operator: "with", operand: private_msg.id},
];
set_filter(terms);
unread_ids = candidate_ids();
assert.deepEqual(unread_ids, []);
message_lists.set_current(undefined); message_lists.set_current(undefined);
blueslip.expect("error", "unexpected call to get_first_unread_info"); blueslip.expect("error", "unexpected call to get_first_unread_info");
assert_unread_info({ assert_unread_info({

View File

@ -16,6 +16,7 @@ from typing import (
) )
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -44,8 +45,12 @@ from sqlalchemy.types import ARRAY, Boolean, Integer, Text
from typing_extensions import TypeAlias, override from typing_extensions import TypeAlias, override
from zerver.lib.addressee import get_user_profiles, get_user_profiles_by_ids from zerver.lib.addressee import get_user_profiles, get_user_profiles_by_ids
from zerver.lib.exceptions import ErrorCode, JsonableError from zerver.lib.exceptions import ErrorCode, JsonableError, MissingAuthenticationError
from zerver.lib.message import get_first_visible_message_id from zerver.lib.message import (
access_message,
access_web_public_message,
get_first_visible_message_id,
)
from zerver.lib.narrow_predicate import channel_operators, channels_operators from zerver.lib.narrow_predicate import channel_operators, channels_operators
from zerver.lib.recipient_users import recipient_for_user_profiles from zerver.lib.recipient_users import recipient_for_user_profiles
from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection
@ -81,6 +86,7 @@ from zerver.models import (
UserMessage, UserMessage,
UserProfile, UserProfile,
) )
from zerver.models.recipients import get_direct_message_group_user_ids
from zerver.models.streams import get_active_streams from zerver.models.streams import get_active_streams
from zerver.models.users import ( from zerver.models.users import (
get_user_by_id_in_realm_including_cross_realm, get_user_by_id_in_realm_including_cross_realm,
@ -124,6 +130,7 @@ class NarrowParameter(BaseModel):
"sender", "sender",
"group-pm-with", "group-pm-with",
"dm-including", "dm-including",
"with",
] ]
operators_supporting_ids = ["pm-with", "dm"] operators_supporting_ids = ["pm-with", "dm"]
operators_non_empty_operand = {"search"} operators_non_empty_operand = {"search"}
@ -161,6 +168,7 @@ def is_spectator_compatible(narrow: Iterable[NarrowParameter]) -> bool:
"search", "search",
"near", "near",
"id", "id",
"with",
] ]
for element in narrow: for element in narrow:
operator = element.operator operator = element.operator
@ -200,6 +208,19 @@ class BadNarrowOperatorError(JsonableError):
return _("Invalid narrow operator: {desc}") return _("Invalid narrow operator: {desc}")
class InvalidOperatorCombinationError(JsonableError):
code = ErrorCode.BAD_NARROW
data_fields = ["desc"]
def __init__(self, desc: str) -> None:
self.desc: str = desc
@staticmethod
@override
def msg_format() -> str:
return _("Invalid narrow operator combination: {desc}")
ConditionTransform: TypeAlias = Callable[[ClauseElement], ClauseElement] ConditionTransform: TypeAlias = Callable[[ClauseElement], ClauseElement]
# These delimiters will not appear in rendered messages or HTML-escaped topics. # These delimiters will not appear in rendered messages or HTML-escaped topics.
@ -863,6 +884,82 @@ def get_channel_from_narrow_access_unchecked(
return None return None
# This function implements the core logic of the `with` operator,
# which is designed to support permanent links to a topic that
# robustly function if the topic is moved.
#
# The with operator accepts a message ID as an operand. If the
# message ID does not exist or is otherwise not accessible to the
# current user, then it has no effect.
#
# Otherwise, the narrow terms are mutated to remove any
# channel/topic/dm operators, replacing them with the appropriate
# operators for the conversation view containing the targeted message.
def update_narrow_terms_containing_with_operator(
realm: Realm,
maybe_user_profile: Union[UserProfile, AnonymousUser],
narrow: Optional[List[NarrowParameter]],
) -> Optional[List[NarrowParameter]]:
if narrow is None:
return narrow
with_operator_terms = list(filter(lambda term: term.operator == "with", narrow))
if len(with_operator_terms) > 1:
raise InvalidOperatorCombinationError(_("Duplicate 'with' operators."))
elif len(with_operator_terms) == 0:
return narrow
with_term = with_operator_terms[0]
narrow.remove(with_term)
try:
message_id = int(with_term.operand)
except ValueError:
# TODO: This probably should be handled earlier.
raise BadNarrowOperatorError(_("Invalid 'with' operator"))
if maybe_user_profile.is_authenticated:
try:
message = access_message(maybe_user_profile, message_id)
except JsonableError:
return narrow
else:
try:
message = access_web_public_message(realm, message_id)
except MissingAuthenticationError:
return narrow
# TODO: It would be better if the legacy names here are canonicalized
# while building a NarrowParameter.
filtered_terms = [
term
for term in narrow
if term.operator not in ["stream", "channel", "topic", "dm", "pm-with"]
]
if message.recipient.type == Recipient.STREAM:
channel_id = message.recipient.type_id
topic = message.topic_name()
channel_conversation_terms = [
NarrowParameter(operator="channel", operand=channel_id),
NarrowParameter(operator="topic", operand=topic),
]
return channel_conversation_terms + filtered_terms
elif message.recipient.type == Recipient.PERSONAL:
dm_conversation_terms = [
NarrowParameter(operator="dm", operand=[message.recipient.type_id])
]
return dm_conversation_terms + filtered_terms
elif message.recipient.type == Recipient.DIRECT_MESSAGE_GROUP:
huddle_user_ids = list(get_direct_message_group_user_ids(message.recipient))
dm_conversation_terms = [NarrowParameter(operator="dm", operand=huddle_user_ids)]
return dm_conversation_terms + filtered_terms
raise AssertionError("Invalid recipient type")
def exclude_muting_conditions( def exclude_muting_conditions(
user_profile: UserProfile, narrow: Optional[List[NarrowParameter]] user_profile: UserProfile, narrow: Optional[List[NarrowParameter]]
) -> List[ClauseElement]: ) -> List[ClauseElement]:

View File

@ -47,7 +47,7 @@ from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection
from zerver.lib.streams import StreamDict, create_streams_if_needed, get_public_streams_queryset from zerver.lib.streams import StreamDict, create_streams_if_needed, get_public_streams_queryset
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import HostRequestMock, get_user_messages, queries_captured from zerver.lib.test_helpers import HostRequestMock, get_user_messages, queries_captured
from zerver.lib.topic import MATCH_TOPIC, RESOLVED_TOPIC_PREFIX, TOPIC_NAME from zerver.lib.topic import MATCH_TOPIC, RESOLVED_TOPIC_PREFIX, TOPIC_NAME, messages_for_topic
from zerver.lib.types import UserDisplayRecipient from zerver.lib.types import UserDisplayRecipient
from zerver.lib.upload import create_attachment from zerver.lib.upload import create_attachment
from zerver.lib.url_encoding import near_message_url from zerver.lib.url_encoding import near_message_url
@ -2675,6 +2675,187 @@ class GetOldMessagesTest(ZulipTestCase):
""" """
) )
def test_get_visible_messages_using_narrow_with(self) -> None:
hamlet = self.example_user("hamlet")
othello = self.example_user("othello")
iago = self.example_user("iago")
realm = hamlet.realm
self.login("iago")
self.make_stream("dev team", invite_only=True, history_public_to_subscribers=False)
self.subscribe(iago, "dev team")
# Test `with` operator effective when targeting a topic with
# message which can be accessed by the user.
msg_id = self.send_stream_message(iago, "dev team", topic_name="test")
narrow = [
dict(operator="channel", operand="dev team"),
dict(operator="topic", operand="other_topic"),
dict(operator="with", operand=msg_id),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
self.assertEqual(results["messages"][0]["id"], msg_id)
# Notably we returned the message with its actual topic.
self.assertEqual(results["messages"][0]["subject"], "test")
# Test `with` operator without channel/topic operators.
narrow = [
dict(operator="with", operand=msg_id),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
self.assertEqual(results["messages"][0]["id"], msg_id)
# Test `with` operator ineffective when targeting a topic with
# message that can not be accessed by the user.
# Since !history_public_to_subscribers, hamlet cannot view.
self.subscribe(hamlet, "dev team")
self.login("hamlet")
narrow = [
dict(operator="channel", operand="dev team"),
dict(operator="topic", operand="test"),
dict(operator="with", operand=msg_id),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
self.assert_length(results["messages"], 0)
# Same result with topic specified incorrectly
narrow = [
dict(operator="channel", operand="dev team"),
dict(operator="topic", operand="wrong_guess"),
dict(operator="with", operand=msg_id),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
self.assert_length(results["messages"], 0)
# If just with is specified, we get messages a la combined feed,
# but not the target message.
narrow = [
dict(operator="with", operand=msg_id),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
self.assertNotIn(msg_id, [message["id"] for message in results["messages"]])
# Test `with` operator is effective when targeting personal
# messages with message id, and returns messages of that narrow.
#
# This will be relevant if we allow moving DMs in the future.
#
# First, attempt to view a message ID we can't access.
msg_ids = [self.send_personal_message(iago, othello) for _ in range(2)]
with_narrow = [
# Important: We pass the wrong conversation.
dict(operator="dm", operand=[hamlet.id]),
dict(operator="with", operand=msg_ids[0]),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(with_narrow).decode()))
self.assertNotIn(msg_id, [message["id"] for message in results["messages"]])
# Now switch to a user how does have access.
self.login("iago")
with_narrow = [
# Important: We pass the wrong conversation.
dict(operator="dm", operand=[hamlet.id]),
dict(operator="with", operand=msg_ids[0]),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(with_narrow).decode()))
for msg in results["messages"]:
self.assertIn(msg["id"], msg_ids)
# Test `with` operator is effective when targeting direct
# messages group with message id.
iago = self.example_user("iago")
cordelia = self.example_user("cordelia")
hamlet = self.example_user("othello")
msg_ids = [self.send_group_direct_message(iago, [cordelia, hamlet]) for _ in range(2)]
with_narrow = [
# Again, query the wrong conversation.
dict(operator="dm", operand=[hamlet.id]),
dict(operator="with", operand=msg_ids[0]),
]
results = self.get_and_check_messages(dict(narrow=orjson.dumps(with_narrow).decode()))
for msg in results["messages"]:
self.assertIn(msg["id"], msg_ids)
# Test `with` operator effective with spectator access when
# spectator has access to message.
self.logout()
self.setup_web_public_test(5)
channel = get_stream("web-public-channel", realm)
assert channel.recipient_id is not None
message_ids = messages_for_topic(realm.id, channel.recipient_id, "test").values_list(
"id", flat=True
)
web_public_narrow = [
dict(operator="channels", operand="web-public", negated=False),
dict(operator="channel", operand="web-public-channel"),
# Important: Pass a topic that doesn't contain the target message
dict(operator="topic", operand="wrong topic"),
dict(operator="with", operand=message_ids[0]),
]
post_params = {
"anchor": 0,
"num_before": 0,
"num_after": 5,
"narrow": orjson.dumps(web_public_narrow).decode(),
}
result = self.client_get("/json/messages", dict(post_params))
self.verify_web_public_query_result_success(result, 5)
# Test `with` operator ineffective when spectator does not have
# access to message, by trying to access the same set of messages
# but when the spectator access is not allowed.
do_set_realm_property(hamlet.realm, "enable_spectator_access", False, acting_user=hamlet)
result = self.client_get("/json/messages", dict(post_params))
self.check_unauthenticated_response(result)
# Test request with multiple `with` operators raises
# InvalidOperatorCombinationError
self.login("iago")
iago = self.example_user("iago")
msg_id_1 = self.send_stream_message(iago, "Verona")
msg_id_2 = self.send_stream_message(iago, "Scotland")
narrow = [
dict(operator="channel", operand="Verona"),
dict(operator="with", operand=msg_id_1),
dict(operator="topic", operand="test"),
dict(operator="with", operand=msg_id_2),
]
post_params = {
"anchor": msg_id_1,
"num_before": 0,
"num_after": 5,
"narrow": orjson.dumps(narrow).decode(),
}
result = self.client_get("/json/messages", dict(post_params))
self.assert_json_error(
result, "Invalid narrow operator combination: Duplicate 'with' operators."
)
# Test request with an invalid message id for `with` operator fails.
msg_id = self.send_stream_message(iago, "Verona", topic_name="Invalid id")
narrow = [
dict(operator="channel", operand="Verona"),
dict(operator="topic", operand="Invalid id"),
dict(operator="with", operand="3.2"),
]
post_params = {
"anchor": msg_id,
"num_before": 0,
"num_after": 5,
"narrow": orjson.dumps(narrow).decode(),
}
result = self.client_get("/json/messages", dict(post_params))
self.assert_json_error(result, "Invalid narrow operator: Invalid 'with' operator")
@override_settings(USING_PGROONGA=False) @override_settings(USING_PGROONGA=False)
def test_messages_in_narrow(self) -> None: def test_messages_in_narrow(self) -> None:
user = self.example_user("cordelia") user = self.example_user("cordelia")
@ -3615,7 +3796,7 @@ class GetOldMessagesTest(ZulipTestCase):
), ),
] ]
for operand in ["id", "sender", "channel", "dm-including", "group-pm-with"]: for operand in ["id", "sender", "channel", "dm-including", "group-pm-with", "with"]:
self.exercise_bad_narrow_operand_using_dict_api(operand, invalid_operands) self.exercise_bad_narrow_operand_using_dict_api(operand, invalid_operands)
# str or int list is required for "dm" and "pm-with" operator # str or int list is required for "dm" and "pm-with" operator

View File

@ -21,6 +21,7 @@ from zerver.lib.narrow import (
is_spectator_compatible, is_spectator_compatible,
is_web_public_narrow, is_web_public_narrow,
parse_anchor_value, parse_anchor_value,
update_narrow_terms_containing_with_operator,
) )
from zerver.lib.request import RequestNotes from zerver.lib.request import RequestNotes
from zerver.lib.response import json_success from zerver.lib.response import json_success
@ -114,7 +115,9 @@ def get_messages_backend(
client_gravatar: Json[bool] = True, client_gravatar: Json[bool] = True,
apply_markdown: Json[bool] = True, apply_markdown: Json[bool] = True,
) -> HttpResponse: ) -> HttpResponse:
realm = get_valid_realm_from_request(request)
anchor = parse_anchor_value(anchor_val, use_first_unread_anchor_val) anchor = parse_anchor_value(anchor_val, use_first_unread_anchor_val)
narrow = update_narrow_terms_containing_with_operator(realm, maybe_user_profile, narrow)
if num_before + num_after > MAX_MESSAGES_PER_FETCH: if num_before + num_after > MAX_MESSAGES_PER_FETCH:
raise JsonableError( raise JsonableError(
_("Too many messages requested (maximum {max_messages}).").format( _("Too many messages requested (maximum {max_messages}).").format(
@ -124,7 +127,6 @@ def get_messages_backend(
if num_before > 0 and num_after > 0 and not include_anchor: if num_before > 0 and num_after > 0 and not include_anchor:
raise JsonableError(_("The anchor can only be excluded at an end of the range")) raise JsonableError(_("The anchor can only be excluded at an end of the range"))
realm = get_valid_realm_from_request(request)
if not maybe_user_profile.is_authenticated: if not maybe_user_profile.is_authenticated:
# If user is not authenticated, clients must include # If user is not authenticated, clients must include
# `streams:web-public` in their narrow query to indicate this # `streams:web-public` in their narrow query to indicate this