mirror of
https://github.com/zulip/zulip.git
synced 2025-11-02 13:03:29 +00:00
In the move topic modal, the `new_topic_name` input is disabled if
the user doesn't have permission to move messages between topics.
This commit fixes a bug where `new_topic_name` is undefined since its
input is disabled. This causes `show_topic_already_exists_warning()`
to throw an AssertionError. Hence, the warning is not shown.
Specifically, this bug occurs when a user moves a topic to an
already existing topic in a different channel when he has permission
to move messages between channels but not between topics.
(cherry picked from commit 62745ddccb)
956 lines
39 KiB
TypeScript
956 lines
39 KiB
TypeScript
import $ from "jquery";
|
|
import assert from "minimalistic-assert";
|
|
import type * as tippy from "tippy.js";
|
|
import {z} from "zod";
|
|
|
|
import render_inline_decorated_stream_name from "../templates/inline_decorated_stream_name.hbs";
|
|
import render_inline_stream_or_topic_reference from "../templates/inline_stream_or_topic_reference.hbs";
|
|
import render_topic_already_exists_warning_banner from "../templates/modal_banner/topic_already_exists_warning_banner.hbs";
|
|
import render_move_topic_to_stream from "../templates/move_topic_to_stream.hbs";
|
|
import render_left_sidebar_stream_actions_popover from "../templates/popovers/left_sidebar/left_sidebar_stream_actions_popover.hbs";
|
|
|
|
import * as blueslip from "./blueslip.ts";
|
|
import type {Typeahead} from "./bootstrap_typeahead.ts";
|
|
import * as browser_history from "./browser_history.ts";
|
|
import * as clipboard_handler from "./clipboard_handler.ts";
|
|
import * as compose_banner from "./compose_banner.ts";
|
|
import * as composebox_typeahead from "./composebox_typeahead.ts";
|
|
import * as dialog_widget from "./dialog_widget.ts";
|
|
import * as dropdown_widget from "./dropdown_widget.ts";
|
|
import * as hash_util from "./hash_util.ts";
|
|
import {$t, $t_html} from "./i18n.ts";
|
|
import * as message_edit from "./message_edit.ts";
|
|
import * as message_lists from "./message_lists.ts";
|
|
import type {Message} from "./message_store.ts";
|
|
import * as message_util from "./message_util.ts";
|
|
import * as message_view from "./message_view.ts";
|
|
import * as narrow_state from "./narrow_state.ts";
|
|
import * as popover_menus from "./popover_menus.ts";
|
|
import {left_sidebar_tippy_options} from "./popover_menus.ts";
|
|
import {web_channel_default_view_values} from "./settings_config.ts";
|
|
import * as settings_data from "./settings_data.ts";
|
|
import {realm} from "./state_data.ts";
|
|
import * as stream_data from "./stream_data.ts";
|
|
import * as stream_settings_api from "./stream_settings_api.ts";
|
|
import * as stream_settings_components from "./stream_settings_components.ts";
|
|
import * as stream_settings_ui from "./stream_settings_ui.ts";
|
|
import * as stream_topic_history from "./stream_topic_history.ts";
|
|
import * as sub_store from "./sub_store.ts";
|
|
import * as ui_report from "./ui_report.ts";
|
|
import * as ui_util from "./ui_util.ts";
|
|
import * as unread from "./unread.ts";
|
|
import * as unread_ops from "./unread_ops.ts";
|
|
import {user_settings} from "./user_settings.ts";
|
|
import * as util from "./util.ts";
|
|
|
|
// In this module, we manage stream popovers
|
|
// that pop up from the left sidebar.
|
|
let stream_widget_value: number | undefined;
|
|
let move_topic_to_stream_topic_typeahead: Typeahead<string> | undefined;
|
|
const last_propagate_mode_for_conversation = new Map<string, string>();
|
|
|
|
export function stream_sidebar_menu_handle_keyboard(key: string): void {
|
|
if (popover_menus.is_color_picker_popover_displayed()) {
|
|
const $color_picker_popover_instance = popover_menus.get_color_picker_popover();
|
|
if (!$color_picker_popover_instance) {
|
|
return;
|
|
}
|
|
popover_menus.sidebar_menu_instance_handle_keyboard($color_picker_popover_instance, key);
|
|
return;
|
|
}
|
|
const $stream_actions_popover_instance = popover_menus.get_stream_actions_popover();
|
|
if (!$stream_actions_popover_instance) {
|
|
return;
|
|
}
|
|
popover_menus.sidebar_menu_instance_handle_keyboard($stream_actions_popover_instance, key);
|
|
}
|
|
|
|
export function elem_to_stream_id($elem: JQuery): number {
|
|
const stream_id = Number.parseInt($elem.attr("data-stream-id")!, 10);
|
|
|
|
if (stream_id === undefined) {
|
|
blueslip.error("could not find stream id");
|
|
}
|
|
|
|
return stream_id;
|
|
}
|
|
|
|
export function hide_stream_popover(instance: tippy.Instance): void {
|
|
ui_util.hide_left_sidebar_menu_icon();
|
|
instance.destroy();
|
|
popover_menus.popover_instances.stream_actions_popover = null;
|
|
}
|
|
|
|
function stream_popover_sub(
|
|
e: JQuery.ClickEvent<tippy.PopperElement, undefined>,
|
|
): sub_store.StreamSubscription {
|
|
const $elem = $(e.currentTarget).parents("ul");
|
|
const stream_id = elem_to_stream_id($elem);
|
|
const sub = sub_store.get(stream_id);
|
|
if (!sub) {
|
|
throw new Error(`Unknown stream ${stream_id}`);
|
|
}
|
|
return sub;
|
|
}
|
|
|
|
function build_stream_popover(opts: {elt: HTMLElement; stream_id: number}): void {
|
|
const {elt, stream_id} = opts;
|
|
|
|
// This will allow the user to close the popover by clicking
|
|
// on the reference element if the popover is already open.
|
|
if (popover_menus.get_stream_actions_popover()?.reference === elt) {
|
|
return;
|
|
}
|
|
|
|
const stream_hash = hash_util.by_stream_url(stream_id);
|
|
const show_go_to_channel_feed =
|
|
user_settings.web_channel_default_view !==
|
|
web_channel_default_view_values.channel_feed.code;
|
|
const stream_unread = unread.unread_count_info_for_stream(stream_id);
|
|
const stream_unread_count = stream_unread.unmuted_count + stream_unread.muted_count;
|
|
const has_unread_messages = stream_unread_count > 0;
|
|
const content = render_left_sidebar_stream_actions_popover({
|
|
stream: {
|
|
...sub_store.get(stream_id),
|
|
url: browser_history.get_full_url(stream_hash),
|
|
},
|
|
has_unread_messages,
|
|
show_go_to_channel_feed,
|
|
});
|
|
|
|
popover_menus.toggle_popover_menu(elt, {
|
|
// Add a delay to separate `hideOnClick` and `onShow` so that
|
|
// `onShow` is called after `hideOnClick`.
|
|
// See https://github.com/atomiks/tippyjs/issues/230 for more details.
|
|
delay: [100, 0],
|
|
...left_sidebar_tippy_options,
|
|
onCreate(instance) {
|
|
const $popover = $(instance.popper);
|
|
$popover.addClass("stream-popover-root");
|
|
instance.setContent(ui_util.parse_html(content));
|
|
},
|
|
onMount(instance) {
|
|
const $popper = $(instance.popper);
|
|
popover_menus.popover_instances.stream_actions_popover = instance;
|
|
ui_util.show_left_sidebar_menu_icon(elt);
|
|
|
|
// Go to channel feed instead of first topic.
|
|
$popper.on("click", ".stream-popover-go-to-channel-feed", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
message_view.show(
|
|
[
|
|
{
|
|
operator: "stream",
|
|
operand: sub.stream_id.toString(),
|
|
},
|
|
],
|
|
{trigger: "stream-popover"},
|
|
);
|
|
});
|
|
|
|
// Stream settings
|
|
$popper.on("click", ".open_stream_settings", (e) => {
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
|
|
// Admin can change any stream's name & description either stream is public or
|
|
// private, subscribed or unsubscribed.
|
|
const can_change_stream_permissions =
|
|
stream_data.can_change_permissions_requiring_metadata_access(sub);
|
|
let stream_edit_hash = hash_util.channels_settings_edit_url(sub, "general");
|
|
if (!can_change_stream_permissions) {
|
|
stream_edit_hash = hash_util.channels_settings_edit_url(sub, "personal");
|
|
}
|
|
browser_history.go_to_location(stream_edit_hash);
|
|
});
|
|
|
|
// Pin/unpin
|
|
$popper.on("click", ".pin_to_top", (e) => {
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
stream_settings_ui.toggle_pin_to_top_stream(sub);
|
|
e.stopPropagation();
|
|
});
|
|
|
|
// Mark all messages in stream as read
|
|
$popper.on("click", ".mark_stream_as_read", (e) => {
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
unread_ops.mark_stream_as_read(sub.stream_id);
|
|
e.stopPropagation();
|
|
});
|
|
|
|
// Mark all messages in stream as unread
|
|
$popper.on("click", ".mark_stream_as_unread", (e) => {
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
unread_ops.mark_stream_as_unread(sub.stream_id);
|
|
e.stopPropagation();
|
|
});
|
|
|
|
// Mute/unmute
|
|
$popper.on("click", ".toggle_stream_muted", (e) => {
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
stream_settings_api.set_stream_property(sub, {
|
|
property: "is_muted",
|
|
value: !sub.is_muted,
|
|
});
|
|
e.stopPropagation();
|
|
});
|
|
|
|
// Unsubscribe
|
|
$popper.on("click", ".popover_sub_unsub_button", (e) => {
|
|
const sub = stream_popover_sub(e);
|
|
hide_stream_popover(instance);
|
|
stream_settings_components.sub_or_unsub(sub);
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
|
|
$popper.on("click", ".copy_stream_link", (e) => {
|
|
assert(e.currentTarget instanceof HTMLElement);
|
|
clipboard_handler.popover_copy_link_to_clipboard(instance, $(e.currentTarget));
|
|
});
|
|
},
|
|
onHidden(instance) {
|
|
hide_stream_popover(instance);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function get_message_placement_from_server(
|
|
current_stream_id: number,
|
|
topic_name: string,
|
|
current_message_id: number,
|
|
): Promise<"first" | "intermediate" | "last"> {
|
|
return new Promise((resolve) => {
|
|
message_edit.is_message_oldest_or_newest(
|
|
current_stream_id,
|
|
topic_name,
|
|
current_message_id,
|
|
(is_oldest, is_newest) => {
|
|
if (is_oldest) {
|
|
resolve("first");
|
|
} else if (is_newest) {
|
|
resolve("last");
|
|
} else {
|
|
resolve("intermediate");
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async function get_message_placement_in_conversation(
|
|
current_stream_id: number,
|
|
topic_name: string,
|
|
current_message_id: number,
|
|
): Promise<"first" | "intermediate" | "last"> {
|
|
assert(message_lists.current !== undefined);
|
|
// First we check if the placement of the message can be determined
|
|
// in the current message list. This allows us to avoid a server call
|
|
// in most cases.
|
|
|
|
if (message_lists.current.data.filter.supports_collapsing_recipients()) {
|
|
// Next we check if we are in a conversation view. If we are
|
|
// in a conversation view, we check if the message is the
|
|
// first or the last message in the current view. If not, we
|
|
// can conclude that the message is an intermediate message.
|
|
//
|
|
// It's safe to assume message_lists.current.data is non-empty, because
|
|
// current_message_id must be present in it.
|
|
if (message_lists.current.data.filter.is_conversation_view()) {
|
|
if (
|
|
message_lists.current.data.fetch_status.has_found_oldest() &&
|
|
message_lists.current.data.first()?.id === current_message_id
|
|
) {
|
|
return "first";
|
|
} else if (
|
|
message_lists.current.data.fetch_status.has_found_newest() &&
|
|
message_lists.current.data.last()?.id === current_message_id
|
|
) {
|
|
return "last";
|
|
}
|
|
return "intermediate";
|
|
}
|
|
|
|
// If we are not in a conversation view, but still know
|
|
// the view contains the entire conversation, we check if
|
|
// we can find the adjacent messages in the current view
|
|
// through which we can determine if the message is an
|
|
// intermediate message or not.
|
|
const msg_list = message_lists.current.data.all_messages();
|
|
let found_newer_matching_message = false;
|
|
let found_older_matching_message = false;
|
|
const current_dict = {
|
|
stream_id: current_stream_id,
|
|
topic: topic_name,
|
|
};
|
|
|
|
for (let i = msg_list.length - 1; i >= 0; i -= 1) {
|
|
const message = msg_list[i];
|
|
if (message?.type === "stream" && util.same_stream_and_topic(current_dict, message)) {
|
|
if (message.id > current_message_id) {
|
|
found_newer_matching_message = true;
|
|
} else if (message.id < current_message_id) {
|
|
found_older_matching_message = true;
|
|
}
|
|
|
|
if (found_newer_matching_message && found_older_matching_message) {
|
|
return "intermediate";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
message_lists.current.data.fetch_status.has_found_newest() &&
|
|
!found_newer_matching_message
|
|
) {
|
|
return "last";
|
|
}
|
|
if (
|
|
message_lists.current.data.fetch_status.has_found_oldest() &&
|
|
!found_older_matching_message
|
|
) {
|
|
return "first";
|
|
}
|
|
}
|
|
|
|
// In case we are unable to determine the placement of the message
|
|
// in the current message list, we make a server call to determine
|
|
// the placement.
|
|
return await get_message_placement_from_server(
|
|
current_stream_id,
|
|
topic_name,
|
|
current_message_id,
|
|
);
|
|
}
|
|
|
|
export async function build_move_topic_to_stream_popover(
|
|
current_stream_id: number,
|
|
topic_name: string,
|
|
only_topic_edit: boolean,
|
|
message?: Message,
|
|
): Promise<void> {
|
|
const current_stream_name = sub_store.get(current_stream_id)!.name;
|
|
const stream = sub_store.get(current_stream_id);
|
|
const topic_display_name = util.get_final_topic_display_name(topic_name);
|
|
const empty_string_topic_display_name = util.get_final_topic_display_name("");
|
|
const is_empty_string_topic = topic_name === "";
|
|
const args: {
|
|
topic_name: string;
|
|
empty_string_topic_display_name: string;
|
|
current_stream_id: number;
|
|
notify_new_thread: boolean;
|
|
notify_old_thread: boolean;
|
|
from_message_actions_popover: boolean;
|
|
only_topic_edit: boolean;
|
|
disable_topic_input?: boolean;
|
|
message_placement?: "first" | "intermediate" | "last";
|
|
stream: sub_store.StreamSubscription | undefined;
|
|
max_topic_length: number;
|
|
} = {
|
|
topic_name,
|
|
empty_string_topic_display_name,
|
|
current_stream_id,
|
|
stream,
|
|
notify_new_thread: message_edit.notify_new_thread_default,
|
|
notify_old_thread: message_edit.notify_old_thread_default,
|
|
from_message_actions_popover: message !== undefined,
|
|
only_topic_edit,
|
|
max_topic_length: realm.max_topic_length,
|
|
};
|
|
|
|
// When the modal is opened for moving the whole topic from left sidebar,
|
|
// we do not have any message object and so we disable the stream input
|
|
// based on the can_move_messages_between_channels_group setting and topic
|
|
// input based on can_move_messages_between_topics_group. In other cases, message object is
|
|
// available and thus we check the time-based permissions as well in the
|
|
// below if block to enable or disable the stream and topic input.
|
|
let disable_stream_input = !settings_data.user_can_move_messages_between_streams();
|
|
args.disable_topic_input = !settings_data.user_can_move_messages_to_another_topic();
|
|
|
|
let modal_heading;
|
|
if (only_topic_edit) {
|
|
modal_heading = $t_html(
|
|
{defaultMessage: "Rename <z-stream-or-topic></z-stream-or-topic>"},
|
|
{
|
|
"z-stream-or-topic": () =>
|
|
render_inline_stream_or_topic_reference({
|
|
topic_display_name,
|
|
is_empty_string_topic,
|
|
stream,
|
|
show_colored_icon: true,
|
|
}),
|
|
},
|
|
);
|
|
} else {
|
|
modal_heading = $t_html(
|
|
{defaultMessage: "Move <z-stream-or-topic></z-stream-or-topic>"},
|
|
{
|
|
"z-stream-or-topic": () =>
|
|
render_inline_stream_or_topic_reference({
|
|
topic_display_name,
|
|
is_empty_string_topic,
|
|
stream,
|
|
show_colored_icon: true,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
if (message !== undefined) {
|
|
modal_heading = $t_html(
|
|
{defaultMessage: "Move messages from <z-stream-or-topic></z-stream-or-topic>"},
|
|
{
|
|
"z-stream-or-topic": () =>
|
|
render_inline_stream_or_topic_reference({
|
|
stream,
|
|
topic_display_name,
|
|
is_empty_string_topic,
|
|
show_colored_icon: true,
|
|
}),
|
|
},
|
|
);
|
|
// We disable topic input only for modal is opened from the message actions
|
|
// popover and not when moving the whole topic from left sidebar. This is
|
|
// because topic editing permission depend on message and we do not have
|
|
// any message object when opening the modal and the first message of
|
|
// topic is fetched from the server after clicking submit.
|
|
// Though, this will be changed soon as we are going to make topic
|
|
// edit permission independent of message.
|
|
|
|
// We potentially got to this function by clicking a button that implied the
|
|
// user would be able to move their message. Give a little bit of buffer in
|
|
// case the button has been around for a bit, e.g. we show the
|
|
// move_message_button (hovering plus icon) as long as the user would have
|
|
// been able to click it at the time the mouse entered the message_row. Also
|
|
// a buffer in case their computer is slow, or stalled for a second, etc
|
|
// If you change this number also change edit_limit_buffer in
|
|
// zerver.actions.message_edit.check_update_message
|
|
const move_limit_buffer = 5;
|
|
args.disable_topic_input = !message_edit.is_topic_editable(message, move_limit_buffer);
|
|
disable_stream_input = !message_edit.is_stream_editable(message, move_limit_buffer);
|
|
|
|
// If message is in a search view, default to "move only this message" option,
|
|
// same as if it were the last message in any view.
|
|
if (narrow_state.is_search_view()) {
|
|
args.message_placement = "last";
|
|
}
|
|
// Else, default option is based on the message placement in the view.
|
|
else {
|
|
args.message_placement = await get_message_placement_in_conversation(
|
|
current_stream_id,
|
|
topic_name,
|
|
message.id,
|
|
);
|
|
}
|
|
}
|
|
|
|
const params_schema = z.object({
|
|
current_stream_id: z.string(),
|
|
new_topic_name: z.string().optional(),
|
|
old_topic_name: z.string(),
|
|
propagate_mode: z.enum(["change_one", "change_later", "change_all"]).optional(),
|
|
send_notification_to_new_thread: z.literal("on").optional(),
|
|
send_notification_to_old_thread: z.literal("on").optional(),
|
|
});
|
|
|
|
function get_params_from_form(): z.output<typeof params_schema> {
|
|
return params_schema.parse(
|
|
Object.fromEntries(
|
|
$("#move_topic_form")
|
|
.serializeArray()
|
|
.map(({name, value}) => [name, value]),
|
|
),
|
|
);
|
|
}
|
|
|
|
function update_submit_button_disabled_state(select_stream_id: number): void {
|
|
const params = get_params_from_form();
|
|
const current_stream_id = params.current_stream_id;
|
|
const new_topic_name = params.new_topic_name?.trim();
|
|
const old_topic_name = params.old_topic_name.trim();
|
|
|
|
// Unlike most topic comparisons in Zulip, we intentionally do
|
|
// a case-sensitive comparison, since adjusting the
|
|
// capitalization of a topic is a valid operation.
|
|
// new_topic_name can be undefined when the new topic input is
|
|
// disabled in case when user does not have permission to edit
|
|
// topic and thus submit button is disabled if stream is also
|
|
// not changed.
|
|
let is_disabled = false;
|
|
if (
|
|
realm.realm_mandatory_topics &&
|
|
(new_topic_name === "" || new_topic_name === "(no topic)")
|
|
) {
|
|
is_disabled = true;
|
|
}
|
|
if (
|
|
Number.parseInt(current_stream_id, 10) === select_stream_id &&
|
|
(new_topic_name === undefined || new_topic_name === old_topic_name)
|
|
) {
|
|
is_disabled = true;
|
|
}
|
|
util.the($<HTMLButtonElement>("#move_topic_modal button.dialog_submit_button")).disabled =
|
|
is_disabled;
|
|
}
|
|
|
|
function move_topic(): void {
|
|
const params = get_params_from_form();
|
|
|
|
const old_topic_name = params.old_topic_name.trim();
|
|
let select_stream_id;
|
|
if (only_topic_edit) {
|
|
select_stream_id = undefined;
|
|
} else {
|
|
select_stream_id = stream_widget_value;
|
|
}
|
|
|
|
let new_topic_name = params.new_topic_name;
|
|
const send_notification_to_new_thread = params.send_notification_to_new_thread === "on";
|
|
const send_notification_to_old_thread = params.send_notification_to_old_thread === "on";
|
|
const current_stream_id = Number.parseInt(params.current_stream_id, 10);
|
|
|
|
if (new_topic_name !== undefined) {
|
|
// new_topic_name can be undefined when the new topic input is disabled when
|
|
// user does not have permission to edit topic.
|
|
new_topic_name = new_topic_name.trim();
|
|
}
|
|
if (old_topic_name === new_topic_name) {
|
|
// We use `undefined` to tell the server that
|
|
// there has been no change in the topic name.
|
|
new_topic_name = undefined;
|
|
}
|
|
if (select_stream_id === current_stream_id) {
|
|
// We use `undefined` to tell the server that
|
|
// there has been no change in stream. This is
|
|
// important for cases when changing stream is
|
|
// not allowed or when changes other than
|
|
// stream-change has been made.
|
|
select_stream_id = undefined;
|
|
}
|
|
|
|
let propagate_mode = "change_all";
|
|
if (message !== undefined) {
|
|
// We already have the message_id here which means that modal is opened using
|
|
// message popover.
|
|
assert(params.propagate_mode !== undefined);
|
|
propagate_mode = params.propagate_mode;
|
|
const toast_params =
|
|
propagate_mode === "change_one"
|
|
? {
|
|
new_stream_id: select_stream_id ?? current_stream_id,
|
|
new_topic_name: new_topic_name ?? old_topic_name,
|
|
}
|
|
: undefined;
|
|
message_edit.move_topic_containing_message_to_stream(
|
|
message.id,
|
|
select_stream_id,
|
|
new_topic_name,
|
|
send_notification_to_new_thread,
|
|
send_notification_to_old_thread,
|
|
propagate_mode,
|
|
toast_params,
|
|
);
|
|
return;
|
|
}
|
|
|
|
dialog_widget.show_dialog_spinner();
|
|
message_edit.with_first_message_id(
|
|
current_stream_id,
|
|
old_topic_name,
|
|
(message_id) => {
|
|
if (message_id === undefined) {
|
|
// There are no messages in the given topic, so we show an error banner
|
|
// and return, preventing any attempts to move a non-existent topic.
|
|
dialog_widget.hide_dialog_spinner();
|
|
ui_report.client_error(
|
|
$t_html({defaultMessage: "There are no messages to move."}),
|
|
$("#move_topic_modal #dialog_error"),
|
|
);
|
|
return;
|
|
}
|
|
message_edit.move_topic_containing_message_to_stream(
|
|
message_id,
|
|
select_stream_id,
|
|
new_topic_name,
|
|
send_notification_to_new_thread,
|
|
send_notification_to_old_thread,
|
|
propagate_mode,
|
|
);
|
|
},
|
|
(xhr) => {
|
|
dialog_widget.hide_dialog_spinner();
|
|
ui_report.error(
|
|
$t_html({defaultMessage: "Error moving topic"}),
|
|
xhr,
|
|
$("#move_topic_modal #dialog_error"),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
function set_stream_topic_typeahead(): void {
|
|
const $topic_input = $<HTMLInputElement>("#move_topic_form input.move_messages_edit_topic");
|
|
assert(stream_widget_value !== undefined);
|
|
const new_stream_name = sub_store.get(stream_widget_value)!.name;
|
|
move_topic_to_stream_topic_typeahead?.unlisten();
|
|
move_topic_to_stream_topic_typeahead = composebox_typeahead.initialize_topic_edit_typeahead(
|
|
$topic_input,
|
|
new_stream_name,
|
|
false,
|
|
);
|
|
}
|
|
|
|
function show_topic_already_exists_warning(): boolean {
|
|
// Don't show warning if the submit button is disabled.
|
|
if ($("#move_topic_modal .dialog_submit_button").expectOne().prop("disabled")) {
|
|
return false;
|
|
}
|
|
// Don't show warning if we are only moving one message.
|
|
if ($("#move_topic_modal select.message_edit_topic_propagate").val() === "change_one") {
|
|
return false;
|
|
}
|
|
let {new_topic_name} = get_params_from_form();
|
|
if (!settings_data.user_can_move_messages_to_another_topic()) {
|
|
// new_topic_name is undefined since the new topic input is disabled when
|
|
// user does not have permission to edit topic.
|
|
new_topic_name = args.topic_name;
|
|
}
|
|
assert(new_topic_name !== undefined);
|
|
// Don't show warning for empty topic as the user is probably
|
|
// about to type a new topic name. Note that if topics are
|
|
// mandatory, then the submit button is disabled, which returns
|
|
// early above.
|
|
if (new_topic_name === "" || new_topic_name === "(no topic)") {
|
|
return false;
|
|
}
|
|
let stream_id: number;
|
|
if (stream_widget_value === undefined) {
|
|
// Set stream_id to current_stream_id since the user is not
|
|
// allowed to edit the stream in topic-edit only UI.
|
|
stream_id = current_stream_id;
|
|
} else {
|
|
stream_id = stream_widget_value;
|
|
}
|
|
const stream_topics = stream_topic_history
|
|
.get_recent_topic_names(stream_id)
|
|
.map((topic) => topic.toLowerCase());
|
|
if (stream_topics.includes(new_topic_name.trim().toLowerCase())) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function maybe_show_topic_already_exists_warning(): void {
|
|
const $move_topic_warning_container = $("#move_topic_modal .move_topic_warning_container");
|
|
if (show_topic_already_exists_warning()) {
|
|
$move_topic_warning_container.html(
|
|
render_topic_already_exists_warning_banner({
|
|
banner_type: compose_banner.WARNING,
|
|
hide_close_button: true,
|
|
classname: "topic_already_exists_warning",
|
|
}),
|
|
);
|
|
$move_topic_warning_container.show();
|
|
} else {
|
|
$move_topic_warning_container.hide();
|
|
}
|
|
}
|
|
|
|
function render_selected_stream(): void {
|
|
assert(stream_widget_value !== undefined);
|
|
const stream = stream_data.get_sub_by_id(stream_widget_value);
|
|
if (stream === undefined) {
|
|
$("#move_topic_to_stream_widget .dropdown_widget_value").text(
|
|
$t({defaultMessage: "Select a channel"}),
|
|
);
|
|
} else {
|
|
$("#move_topic_to_stream_widget .dropdown_widget_value").html(
|
|
render_inline_decorated_stream_name({stream, show_colored_icon: true}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function move_topic_on_update(event: JQuery.ClickEvent, dropdown: {hide: () => void}): void {
|
|
stream_widget_value = Number.parseInt($(event.currentTarget).attr("data-unique-id")!, 10);
|
|
|
|
update_submit_button_disabled_state(stream_widget_value);
|
|
set_stream_topic_typeahead();
|
|
render_selected_stream();
|
|
maybe_show_topic_already_exists_warning();
|
|
|
|
dropdown.hide();
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Move focus to the topic input after a new stream is selected.
|
|
$("#move_topic_form .move_messages_edit_topic").trigger("focus");
|
|
}
|
|
|
|
// The following logic is correct only when
|
|
// both message_lists.current.data.fetch_status.has_found_newest
|
|
// and message_lists.current.data.fetch_status.has_found_oldest are true;
|
|
// otherwise, we cannot be certain of the correct count.
|
|
function get_count_of_messages_to_be_moved(
|
|
selected_option: string,
|
|
message_id?: number,
|
|
): number {
|
|
if (selected_option === "change_one") {
|
|
return 1;
|
|
}
|
|
if (selected_option === "change_later" && message_id !== undefined) {
|
|
return message_util.get_count_of_messages_in_topic_sent_after_current_message(
|
|
current_stream_id,
|
|
topic_name,
|
|
message_id,
|
|
);
|
|
}
|
|
return message_util.get_messages_in_topic(current_stream_id, topic_name).length;
|
|
}
|
|
|
|
function update_move_messages_count_text(selected_option: string, message_id?: number): void {
|
|
const message_move_count = get_count_of_messages_to_be_moved(selected_option, message_id);
|
|
const is_topic_narrowed = narrow_state.narrowed_by_topic_reply();
|
|
const is_stream_narrowed = narrow_state.narrowed_by_stream_reply();
|
|
const is_same_stream = narrow_state.stream_id() === current_stream_id;
|
|
const is_same_topic = narrow_state.topic() === topic_name;
|
|
|
|
const can_have_exact_count_in_narrow =
|
|
(is_stream_narrowed && is_same_stream) ||
|
|
(is_topic_narrowed && is_same_stream && is_same_topic);
|
|
let exact_message_count = false;
|
|
if (selected_option === "change_one") {
|
|
exact_message_count = true;
|
|
} else if (can_have_exact_count_in_narrow) {
|
|
const has_found_newest = message_lists.current?.data.fetch_status.has_found_newest();
|
|
const has_found_oldest = message_lists.current?.data.fetch_status.has_found_oldest();
|
|
|
|
if (selected_option === "change_later" && has_found_newest) {
|
|
exact_message_count = true;
|
|
}
|
|
if (selected_option === "change_all" && has_found_newest && has_found_oldest) {
|
|
exact_message_count = true;
|
|
}
|
|
}
|
|
|
|
let message_text;
|
|
if (exact_message_count) {
|
|
message_text = $t(
|
|
{
|
|
defaultMessage:
|
|
"{count, plural, one {# message} other {# messages}} will be moved.",
|
|
},
|
|
{count: message_move_count},
|
|
);
|
|
} else {
|
|
message_text = $t(
|
|
{
|
|
defaultMessage:
|
|
"At least {count, plural, one {# message} other {# messages}} will be moved.",
|
|
},
|
|
{count: message_move_count},
|
|
);
|
|
}
|
|
|
|
$("#move_messages_count").text(message_text);
|
|
}
|
|
|
|
function update_topic_input_placeholder_visibility(topic_input_value: string): void {
|
|
if (!realm.realm_mandatory_topics) {
|
|
const $topic_not_mandatory_placeholder = $(".move-topic-new-topic-placeholder");
|
|
if (topic_input_value === "") {
|
|
$topic_not_mandatory_placeholder.addClass(
|
|
"move-topic-new-topic-placeholder-visible",
|
|
);
|
|
} else {
|
|
$topic_not_mandatory_placeholder.removeClass(
|
|
"move-topic-new-topic-placeholder-visible",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setup_resize_observer($topic_input: JQuery<HTMLInputElement>): void {
|
|
// Update position of topic typeahead because showing/hiding the
|
|
// "topic already exists" warning changes the size of the modal.
|
|
const update_topic_typeahead_position = new ResizeObserver((_entries) => {
|
|
requestAnimationFrame(() => {
|
|
$topic_input.trigger(new $.Event("typeahead.refreshPosition"));
|
|
});
|
|
});
|
|
const move_topic_form = document.querySelector("#move_topic_form");
|
|
if (move_topic_form) {
|
|
update_topic_typeahead_position.observe(move_topic_form);
|
|
}
|
|
}
|
|
|
|
function move_topic_post_render(): void {
|
|
$("#move_topic_modal .dialog_submit_button").prop("disabled", true);
|
|
$("#move_topic_modal .move_topic_warning_container").hide();
|
|
|
|
const $topic_input = $<HTMLInputElement>("#move_topic_form input.move_messages_edit_topic");
|
|
move_topic_to_stream_topic_typeahead = composebox_typeahead.initialize_topic_edit_typeahead(
|
|
$topic_input,
|
|
current_stream_name,
|
|
false,
|
|
);
|
|
|
|
if (!realm.realm_mandatory_topics) {
|
|
const $topic_not_mandatory_placeholder = $(".move-topic-new-topic-placeholder");
|
|
|
|
if (topic_name === "") {
|
|
$topic_not_mandatory_placeholder.addClass(
|
|
"move-topic-new-topic-placeholder-visible",
|
|
);
|
|
}
|
|
|
|
$topic_input.on("focus", () => {
|
|
if ($topic_input.val() === "") {
|
|
$topic_input.attr("placeholder", "");
|
|
$topic_input.removeClass("empty-topic-display");
|
|
$topic_not_mandatory_placeholder.addClass(
|
|
"move-topic-new-topic-placeholder-visible",
|
|
);
|
|
}
|
|
|
|
$topic_input.one("blur", () => {
|
|
if ($topic_input.val() === "") {
|
|
$topic_not_mandatory_placeholder.removeClass(
|
|
"move-topic-new-topic-placeholder-visible",
|
|
);
|
|
$topic_input.attr("placeholder", empty_string_topic_display_name);
|
|
$topic_input.addClass("empty-topic-display");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
setup_resize_observer($topic_input);
|
|
|
|
if (only_topic_edit) {
|
|
// Set select_stream_id to current_stream_id since we user is not allowed
|
|
// to edit stream in topic-edit only UI.
|
|
const select_stream_id = current_stream_id;
|
|
$topic_input.on("input", () => {
|
|
update_submit_button_disabled_state(select_stream_id);
|
|
maybe_show_topic_already_exists_warning();
|
|
const topic_input_value = $topic_input.val();
|
|
assert(topic_input_value !== undefined);
|
|
update_topic_input_placeholder_visibility(topic_input_value);
|
|
});
|
|
return;
|
|
}
|
|
|
|
stream_widget_value = current_stream_id;
|
|
const streams_list_options = (): dropdown_widget.Option[] =>
|
|
stream_data.get_options_for_dropdown_widget().filter(({stream}) => {
|
|
if (stream.stream_id === current_stream_id) {
|
|
return true;
|
|
}
|
|
return stream_data.can_post_messages_in_stream(stream);
|
|
});
|
|
|
|
new dropdown_widget.DropdownWidget({
|
|
widget_name: "move_topic_to_stream",
|
|
get_options: streams_list_options,
|
|
item_click_callback: move_topic_on_update,
|
|
$events_container: $("#move_topic_modal"),
|
|
tippy_props: {
|
|
// Show dropdown search input below stream selection button.
|
|
offset: [0, 2],
|
|
},
|
|
}).setup();
|
|
|
|
render_selected_stream();
|
|
$("#move_topic_to_stream_widget").prop("disabled", disable_stream_input);
|
|
$topic_input.on("input", () => {
|
|
assert(stream_widget_value !== undefined);
|
|
update_submit_button_disabled_state(stream_widget_value);
|
|
maybe_show_topic_already_exists_warning();
|
|
const topic_input_value = $topic_input.val();
|
|
assert(topic_input_value !== undefined);
|
|
update_topic_input_placeholder_visibility(topic_input_value);
|
|
});
|
|
|
|
if (!args.from_message_actions_popover) {
|
|
update_move_messages_count_text("change_all");
|
|
} else {
|
|
// Generate unique key for this conversation
|
|
const conversation_key = `${current_stream_id}_${topic_name}`;
|
|
let selected_option = String($("#message_move_select_options").val());
|
|
|
|
// If a user has changed the smart defaults of `propagate_mode` to "change_one", we
|
|
// remember that forced change and apply the same default for `propagate_mode` next
|
|
// time when the user tries to move the message of the same topic to save the time
|
|
// of user manually selecting "change_one" every time.
|
|
const previously_used_propagate_mode =
|
|
last_propagate_mode_for_conversation.get(conversation_key);
|
|
if (previously_used_propagate_mode === "change_one") {
|
|
selected_option = "change_one";
|
|
$("#message_move_select_options").val(selected_option);
|
|
}
|
|
|
|
update_move_messages_count_text(selected_option, message?.id);
|
|
|
|
$("#message_move_select_options").on("change", function () {
|
|
selected_option = String($(this).val());
|
|
last_propagate_mode_for_conversation.set(conversation_key, selected_option);
|
|
maybe_show_topic_already_exists_warning();
|
|
update_move_messages_count_text(selected_option, message?.id);
|
|
});
|
|
}
|
|
}
|
|
|
|
function focus_on_move_modal_render(): void {
|
|
if (!args.disable_topic_input) {
|
|
ui_util.place_caret_at_end(util.the($(".move_messages_edit_topic")));
|
|
}
|
|
}
|
|
|
|
dialog_widget.launch({
|
|
html_heading: modal_heading,
|
|
html_body: render_move_topic_to_stream(args),
|
|
html_submit_button: $t_html({defaultMessage: "Confirm"}),
|
|
id: "move_topic_modal",
|
|
form_id: "move_topic_form",
|
|
on_click: move_topic,
|
|
loading_spinner: true,
|
|
on_shown: focus_on_move_modal_render,
|
|
on_hidden() {
|
|
move_topic_to_stream_topic_typeahead = undefined;
|
|
},
|
|
post_render: move_topic_post_render,
|
|
});
|
|
}
|
|
|
|
export function initialize(): void {
|
|
$("#stream_filters").on("click", ".stream-sidebar-menu-icon", function (this: HTMLElement, e) {
|
|
const $stream_li = $(this).parents("li");
|
|
const stream_id = elem_to_stream_id($stream_li);
|
|
|
|
build_stream_popover({
|
|
elt: this,
|
|
stream_id,
|
|
});
|
|
|
|
e.stopPropagation();
|
|
});
|
|
|
|
$("body").on("click", ".inbox-stream-menu", function (this: HTMLElement, e) {
|
|
const stream_id = Number.parseInt($(this).attr("data-stream-id")!, 10);
|
|
|
|
build_stream_popover({
|
|
elt: this,
|
|
stream_id,
|
|
});
|
|
|
|
e.stopPropagation();
|
|
});
|
|
}
|