import re
from typing import List, Match, Tuple

from bs4 import BeautifulSoup

# The phrases in this list will be ignored. The longest phrase is
# tried first; this removes the chance of smaller phrases changing
# the text before longer phrases are tried.
# The errors shown by `tools/check-capitalization` can be added to
# this list without any modification.
IGNORED_PHRASES = [
    # Proper nouns and acronyms
    r"API",
    r"APNS",
    r"Botserver",
    r"Cookie Bot",
    r"DevAuthBackend",
    r"DSN",
    r"Esc",
    r"GCM",
    r"GitHub",
    r"Gravatar",
    r"Help Center",
    r"HTTP",
    r"ID",
    r"IDs",
    r"Inbox",
    r"IP",
    r"JSON",
    r"Kerberos",
    r"LinkedIn",
    r"LDAP",
    r"Markdown",
    r"OTP",
    r"Pivotal",
    r"Recent conversations",
    r"DM",
    r"DMs",
    r"Slack",
    r"Google",
    r"Terms of Service",
    r"Tuesday",
    r"URL",
    r"UUID",
    r"Webathena",
    r"WordPress",
    r"Zephyr",
    r"Zoom",
    r"Zulip",
    r"Zulip Server",
    r"Zulip Account Security",
    r"Zulip Security",
    r"Zulip Cloud",
    r"Zulip Cloud Standard",
    r"Zulip Cloud Plus",
    r"BigBlueButton",
    # Code things
    r"\.zuliprc",
    # BeautifulSoup will remove <z-user> which is horribly confusing,
    # so we need more of the sentence.
    r"<z-user></z-user> will have the same role",
    r"<z-user></z-user> will have the same properties",
    # Things using "I"
    r"I understand",
    r"I'm",
    r"I've",
    r"Topics I participate in",
    r"Topics I send a message to",
    r"Topics I start",
    # Specific short words
    r"beta",
    r"and",
    r"bot",
    r"e\.g\.",
    r"enabled",
    r"signups",
    # Placeholders
    r"keyword",
    r"streamname",
    r"user@example\.com",
    r"example\.com",
    r"acme",
    # Fragments of larger strings
    r"is …",
    r"your subscriptions on your Streams page",
    r"Add global time<br />Everyone sees global times in their own time zone\.",
    r"user",
    r"an unknown operating system",
    r"Go to Settings",
    r"find accounts for another email address",
    # SPECIAL CASES
    # Because topics usually are lower-case, this would look weird if it were capitalized
    r"more topics",
    # Used alone in a parenthetical where capitalized looks worse.
    r"^deprecated$",
    # We want the similar text in the Private Messages section to have the same capitalization.
    r"more conversations",
    r"back to streams",
    # Capital 'i' looks weird in reminders popover
    r"in 1 hour",
    r"in 20 minutes",
    r"in 3 hours",
    # these are used as topics
    r"^new streams$",
    r"^stream events$",
    # These are used as example short names (e.g. an uncapitalized context):
    r"^marketing$",
    r"^cookie$",
    # Used to refer custom time limits
    r"\bN\b",
    # Capital c feels obtrusive in clear status option
    r"clear",
    r"group direct messages with \{recipient\}",
    r"direct messages with \{recipient\}",
    r"direct messages with yourself",
    r"GIF",
    # Emoji name placeholder
    r"leafy green vegetable",
    # Subdomain placeholder
    r"your-organization-url",
    # Used in invite modal
    r"or",
    # Used in GIPHY integration setting. GIFs Rating.
    r"rated Y",
    r"rated G",
    r"rated PG",
    r"rated PG13",
    r"rated R",
    # Used in GIPHY popover.
    r"GIFs",
    r"GIPHY",
    # Used in our case studies
    r"Technical University of Munich",
    r"University of California San Diego",
    # Used in stream creation form
    r"email hidden",
    # Use in compose box.
    r"to send",
    r"to add a new line",
    # Used in showing Notification Bot read receipts message
    "Notification Bot",
    # Used in presence_enabled setting label
    r"invisible mode off",
    # Typeahead suggestions for "Pronouns" custom field type.
    r"he/him",
    r"she/her",
    r"they/them",
    # Used in message-move-time-limit setting label
    r"does not apply to moderators and administrators",
    # Used in message-delete-time-limit setting label
    r"does not apply to administrators",
    # Used as indicator with names for guest users.
    r"guest",
    # Used in pills for deactivated users.
    r"deactivated",
    # This is a reference to a setting/secret and should be lowercase.
    r"zulip_org_id",
]

# Sort regexes in descending order of their lengths. As a result, the
# longer phrases will be ignored first.
IGNORED_PHRASES.sort(key=len, reverse=True)

# Compile regexes to improve performance. This also extracts the
# text using BeautifulSoup and then removes extra whitespaces from
# it. This step enables us to add HTML in our regexes directly.
COMPILED_IGNORED_PHRASES = [
    re.compile(" ".join(BeautifulSoup(regex, "lxml").text.split())) for regex in IGNORED_PHRASES
]

SPLIT_BOUNDARY = "?.!"  # Used to split string into sentences.
SPLIT_BOUNDARY_REGEX = re.compile(rf"[{SPLIT_BOUNDARY}]")

# Regexes which check capitalization in sentences.
DISALLOWED = [
    r"^[a-z](?!\})",  # Checks if the sentence starts with a lower case character.
    r"^[A-Z][a-z]+[\sa-z0-9]+[A-Z]",  # Checks if an upper case character exists
    # after a lower case character when the first character is in upper case.
]
DISALLOWED_REGEX = re.compile(r"|".join(DISALLOWED))

BANNED_WORDS = {
    "realm": "The term realm should not appear in user-facing strings. Use organization instead.",
}


def get_safe_phrase(phrase: str) -> str:
    """
    Safe phrase is in lower case and doesn't contain characters which can
    conflict with split boundaries. All conflicting characters are replaced
    with low dash (_).
    """
    phrase = SPLIT_BOUNDARY_REGEX.sub("_", phrase)
    return phrase.lower()


def replace_with_safe_phrase(matchobj: Match[str]) -> str:
    """
    The idea is to convert IGNORED_PHRASES into safe phrases, see
    `get_safe_phrase()` function. The only exception is when the
    IGNORED_PHRASE is at the start of the text or after a split
    boundary; in this case, we change the first letter of the phrase
    to upper case.
    """
    ignored_phrase = matchobj.group(0)
    safe_string = get_safe_phrase(ignored_phrase)

    start_index = matchobj.start()
    complete_string = matchobj.string

    is_string_start = start_index == 0
    # We expect that there will be one space between split boundary
    # and the next word.
    punctuation = complete_string[max(start_index - 2, 0)]
    is_after_split_boundary = punctuation in SPLIT_BOUNDARY
    if is_string_start or is_after_split_boundary:
        return safe_string.capitalize()

    return safe_string


def get_safe_text(text: str) -> str:
    """
    This returns text which is rendered by BeautifulSoup and is in the
    form that can be split easily and has all IGNORED_PHRASES processed.
    """
    soup = BeautifulSoup(text, "lxml")
    text = " ".join(soup.text.split())  # Remove extra whitespaces.
    for phrase_regex in COMPILED_IGNORED_PHRASES:
        text = phrase_regex.sub(replace_with_safe_phrase, text)

    return text


def is_capitalized(safe_text: str) -> bool:
    sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)
    return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)


def check_banned_words(text: str) -> List[str]:
    lower_cased_text = text.lower()
    errors = []
    for word, reason in BANNED_WORDS.items():
        if word in lower_cased_text:
            # Hack: Should move this into BANNED_WORDS framework; for
            # now, just hand-code the skips:
            if (
                "realm_name" in lower_cased_text
                or "realm_uri" in lower_cased_text
                or "remote_realm_host" in lower_cased_text
            ):
                continue
            kwargs = dict(word=word, text=text, reason=reason)
            msg = "{word} found in '{text}'. {reason}".format(**kwargs)
            errors.append(msg)

    return errors


def check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:
    errors = []
    ignored = []
    banned_word_errors = []
    for text in strings:
        text = " ".join(text.split())  # Remove extra whitespaces.
        safe_text = get_safe_text(text)
        has_ignored_phrase = text != safe_text
        capitalized = is_capitalized(safe_text)
        if not capitalized:
            errors.append(text)
        elif has_ignored_phrase:
            ignored.append(text)

        banned_word_errors.extend(check_banned_words(text))

    return sorted(errors), sorted(ignored), sorted(banned_word_errors)

tools/lib/capitalization.py
import re
from typing import List, Match, Tuple

from bs4 import BeautifulSoup

# The phrases in this list will be ignored. The longest phrase is
# tried first; this removes the chance of smaller phrases changing
# the text before longer phrases are tried.
# The errors shown by `tools/check-capitalization` can be added to
# this list without any modification.
IGNORED_PHRASES = [
    # Proper nouns and acronyms
    r"API",
    r"APNS",
    r"Botserver",
    r"Cookie Bot",
    r"DevAuthBackend",
    r"DSN",
    r"Esc",
    r"GCM",
    r"GitHub",
    r"Gravatar",
    r"Help Center",
    r"HTTP",
    r"ID",
    r"IDs",
    r"Inbox",
    r"IP",
    r"JSON",
    r"Jitsi",
    r"Kerberos",
    r"LinkedIn",
    r"LDAP",
    r"Markdown",
    r"OTP",
    r"Pivotal",
    r"Recent conversations",
    r"DM",
    r"DMs",
    r"Slack",
    r"Google",
    r"Terms of Service",
    r"Tuesday",
    r"URL",
    r"UUID",
    r"Webathena",
    r"WordPress",
    r"Zephyr",
    r"Zoom",
    r"Zulip",
    r"Zulip Server",
    r"Zulip Account Security",
    r"Zulip Security",
    r"Zulip Cloud",
    r"Zulip Cloud Standard",
    r"Zulip Cloud Plus",
    r"BigBlueButton",
    # Code things
    r"\.zuliprc",
    # BeautifulSoup will remove <z-user> which is horribly confusing,
    # so we need more of the sentence.
    r"<z-user></z-user> will have the same role",
    r"<z-user></z-user> will have the same properties",
    # Things using "I"
    r"I understand",
    r"I'm",
    r"I've",
    r"Topics I participate in",
    r"Topics I send a message to",
    r"Topics I start",
    # Specific short words
    r"beta",
    r"and",
    r"bot",
    r"e\.g\.",
    r"enabled",
    r"signups",
    # Placeholders
    r"keyword",
    r"streamname",
    r"user@example\.com",
    r"example\.com",
    r"acme",
    # Fragments of larger strings
    r"is …",
    r"your subscriptions on your Streams page",
    r"Add global time<br />Everyone sees global times in their own time zone\.",
    r"user",
    r"an unknown operating system",
    r"Go to Settings",
    r"find accounts for another email address",
    # SPECIAL CASES
    # Because topics usually are lower-case, this would look weird if it were capitalized
    r"more topics",
    # Used alone in a parenthetical where capitalized looks worse.
    r"^deprecated$",
    # We want the similar text in the Private Messages section to have the same capitalization.
    r"more conversations",
    r"back to streams",
    # Capital 'i' looks weird in reminders popover
    r"in 1 hour",
    r"in 20 minutes",
    r"in 3 hours",
    # these are used as topics
    r"^new streams$",
    r"^stream events$",
    # These are used as example short names (e.g. an uncapitalized context):
    r"^marketing$",
    r"^cookie$",
    # Used to refer custom time limits
    r"\bN\b",
    # Capital c feels obtrusive in clear status option
    r"clear",
    r"group direct messages with \{recipient\}",
    r"direct messages with \{recipient\}",
    r"direct messages with yourself",
    r"GIF",
    # Emoji name placeholder
    r"leafy green vegetable",
    # Subdomain placeholder
    r"your-organization-url",
    # Used in invite modal
    r"or",
    # Used in GIPHY integration setting. GIFs Rating.
    r"rated Y",
    r"rated G",
    r"rated PG",
    r"rated PG13",
    r"rated R",
    # Used in GIPHY popover.
    r"GIFs",
    r"GIPHY",
    # Used in our case studies
    r"Technical University of Munich",
    r"University of California San Diego",
    # Used in stream creation form
    r"email hidden",
    # Use in compose box.
    r"to send",
    r"to add a new line",
    # Used in showing Notification Bot read receipts message
    "Notification Bot",
    # Used in presence_enabled setting label
    r"invisible mode off",
    # Typeahead suggestions for "Pronouns" custom field type.
    r"he/him",
    r"she/her",
    r"they/them",
    # Used in message-move-time-limit setting label
    r"does not apply to moderators and administrators",
    # Used in message-delete-time-limit setting label
    r"does not apply to administrators",
    # Used as indicator with names for guest users.
    r"guest",
    # Used in pills for deactivated users.
    r"deactivated",
    # This is a reference to a setting/secret and should be lowercase.
    r"zulip_org_id",
]

# Sort regexes in descending order of their lengths. As a result, the
# longer phrases will be ignored first.
IGNORED_PHRASES.sort(key=len, reverse=True)

# Compile regexes to improve performance. This also extracts the
# text using BeautifulSoup and then removes extra whitespaces from
# it. This step enables us to add HTML in our regexes directly.
COMPILED_IGNORED_PHRASES = [
    re.compile(" ".join(BeautifulSoup(regex, "lxml").text.split())) for regex in IGNORED_PHRASES
]

SPLIT_BOUNDARY = "?.!"  # Used to split string into sentences.
SPLIT_BOUNDARY_REGEX = re.compile(rf"[{SPLIT_BOUNDARY}]")

# Regexes which check capitalization in sentences.
DISALLOWED = [
    r"^[a-z](?!\})",  # Checks if the sentence starts with a lower case character.
    r"^[A-Z][a-z]+[\sa-z0-9]+[A-Z]",  # Checks if an upper case character exists
    # after a lower case character when the first character is in upper case.
]
DISALLOWED_REGEX = re.compile(r"|".join(DISALLOWED))

BANNED_WORDS = {
    "realm": "The term realm should not appear in user-facing strings. Use organization instead.",
}


def get_safe_phrase(phrase: str) -> str:
    """
    Safe phrase is in lower case and doesn't contain characters which can
    conflict with split boundaries. All conflicting characters are replaced
    with low dash (_).
    """
    phrase = SPLIT_BOUNDARY_REGEX.sub("_", phrase)
    return phrase.lower()


def replace_with_safe_phrase(matchobj: Match[str]) -> str:
    """
    The idea is to convert IGNORED_PHRASES into safe phrases, see
    `get_safe_phrase()` function. The only exception is when the
    IGNORED_PHRASE is at the start of the text or after a split
    boundary; in this case, we change the first letter of the phrase
    to upper case.
    """
    ignored_phrase = matchobj.group(0)
    safe_string = get_safe_phrase(ignored_phrase)

    start_index = matchobj.start()
    complete_string = matchobj.string

    is_string_start = start_index == 0
    # We expect that there will be one space between split boundary
    # and the next word.
    punctuation = complete_string[max(start_index - 2, 0)]
    is_after_split_boundary = punctuation in SPLIT_BOUNDARY
    if is_string_start or is_after_split_boundary:
        return safe_string.capitalize()

    return safe_string


def get_safe_text(text: str) -> str:
    """
    This returns text which is rendered by BeautifulSoup and is in the
    form that can be split easily and has all IGNORED_PHRASES processed.
    """
    soup = BeautifulSoup(text, "lxml")
    text = " ".join(soup.text.split())  # Remove extra whitespaces.
    for phrase_regex in COMPILED_IGNORED_PHRASES:
        text = phrase_regex.sub(replace_with_safe_phrase, text)

    return text


def is_capitalized(safe_text: str) -> bool:
    sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)
    return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)


def check_banned_words(text: str) -> List[str]:
    lower_cased_text = text.lower()
    errors = []
    for word, reason in BANNED_WORDS.items():
        if word in lower_cased_text:
            # Hack: Should move this into BANNED_WORDS framework; for
            # now, just hand-code the skips:
            if (
                "realm_name" in lower_cased_text
                or "realm_uri" in lower_cased_text
                or "remote_realm_host" in lower_cased_text
            ):
                continue
            kwargs = dict(word=word, text=text, reason=reason)
            msg = "{word} found in '{text}'. {reason}".format(**kwargs)
            errors.append(msg)

    return errors


def check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:
    errors = []
    ignored = []
    banned_word_errors = []
    for text in strings:
        text = " ".join(text.split())  # Remove extra whitespaces.
        safe_text = get_safe_text(text)
        has_ignored_phrase = text != safe_text
        capitalized = is_capitalized(safe_text)
        if not capitalized:
            errors.append(text)
        elif has_ignored_phrase:
            ignored.append(text)

        banned_word_errors.extend(check_banned_words(text))

    return sorted(errors), sorted(ignored), sorted(banned_word_errors)

tools/lib/capitalization.py
diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py
index a2c521e40170f..c130abe26b73d 100644
--- a/tools/lib/capitalization.py
+++ b/tools/lib/capitalization.py
@@ -27,6 +27,7 @@
     r"Inbox",
     r"IP",
     r"JSON",
+    r"Jitsi",
     r"Kerberos",
     r"LinkedIn",
     r"LDAP",
diff --git a/web/src/settings_components.ts b/web/src/settings_components.ts
index 13d83b66e0b11..3b0fe73541c4b 100644
--- a/web/src/settings_components.ts
+++ b/web/src/settings_components.ts
@@ -1,5 +1,7 @@
 import $ from "jquery";
 import assert from "minimalistic-assert";
+import type {PopperElement, Props} from "tippy.js";
+import tippy from "tippy.js";
 
 import render_compose_banner from "../templates/compose_banner/compose_banner.hbs";
 
@@ -906,7 +908,44 @@ function enable_or_disable_save_button($subsection_elem: JQuery): void {
         disable_save_btn = should_disable_save_button_for_time_limit_settings(time_limit_settings);
     } else if ($subsection_elem.attr("id") === "org-other-settings") {
         disable_save_btn = should_disable_save_button_for_jitsi_server_url_setting();
+        const $button_wrapper = $subsection_elem.find<PopperElement>(".subsection-changes-save");
+        const tippy_instance = $button_wrapper[0]._tippy;
+        if (disable_save_btn) {
+            // avoid duplication of tippy
+            if (!tippy_instance) {
+                const opts: Partial<Props> = {placement: "top"};
+                initialize_disable_btn_hint_popover(
+                    $button_wrapper,
+                    $t({defaultMessage: "Cannot save invalid Jitsi server URL."}),
+                    opts,
+                );
+            }
+        } else {
+            if (tippy_instance) {
+                tippy_instance.destroy();
+            }
+        }
     }
 
     $subsection_elem.find(".subsection-changes-save button").prop("disabled", disable_save_btn);
 }
+
+export function initialize_disable_btn_hint_popover(
+    $btn_wrapper: JQuery,
+    hint_text: string | undefined,
+    opts: Partial<Props>,
+): void {
+    const tippy_opts: Partial<Props> = {
+        animation: false,
+        hideOnClick: false,
+        placement: "bottom",
+        ...opts,
+    };
+
+    // If hint_text is undefined, we use the HTML content of a
+    // <template> whose id is given by data-tooltip-template-id
+    if (hint_text !== undefined) {
+        tippy_opts.content = hint_text;
+    }
+    tippy($btn_wrapper[0], tippy_opts);
+}
diff --git a/web/src/stream_ui_updates.js b/web/src/stream_ui_updates.js
index 487cfd287da4f..092aeb9348019 100644
--- a/web/src/stream_ui_updates.js
+++ b/web/src/stream_ui_updates.js
@@ -1,5 +1,4 @@
 import $ from "jquery";
-import tippy from "tippy.js";
 
 import render_announce_stream_checkbox from "../templates/stream_settings/announce_stream_checkbox.hbs";
 import render_stream_privacy_icon from "../templates/stream_settings/stream_privacy_icon.hbs";
@@ -7,6 +6,7 @@ import render_stream_settings_tip from "../templates/stream_settings/stream_sett
 
 import * as hash_parser from "./hash_parser";
 import {$t} from "./i18n";
+import * as settings_components from "./settings_components";
 import * as settings_config from "./settings_config";
 import * as settings_data from "./settings_data";
 import * as settings_org from "./settings_org";
@@ -114,24 +114,9 @@ export function update_private_stream_privacy_option_state($container, is_defaul
         .toggleClass("default_stream_private_tooltip", is_default_stream);
 }
 
-export function initialize_disable_btn_hint_popover($btn_wrapper, hint_text) {
-    const opts = {
-        animation: false,
-        hideOnClick: false,
-        placement: "bottom",
-    };
-
-    // If hint_text is undefined, we use the HTML content of a
-    // <template> whose id is given by data-tooltip-template-id
-    if (hint_text !== undefined) {
-        opts.content = hint_text;
-    }
-    tippy($btn_wrapper[0], opts);
-}
-
 export function initialize_cant_subscribe_popover() {
     const $button_wrapper = $(".settings .stream_settings_header .sub_unsub_button_wrapper");
-    initialize_disable_btn_hint_popover($button_wrapper);
+    settings_components.initialize_disable_btn_hint_popover($button_wrapper);
 }
 
 export function set_up_right_panel_section(sub) {
@@ -382,7 +367,10 @@ export function update_add_subscriptions_elements(sub) {
                 defaultMessage: "Only stream members can add users to a private stream.",
             });
         }
-        initialize_disable_btn_hint_popover($add_subscribers_container, tooltip_message);
+        settings_components.initialize_disable_btn_hint_popover(
+            $add_subscribers_container,
+            tooltip_message,
+        );
     }
 }
 
diff --git a/web/src/user_group_edit.js b/web/src/user_group_edit.js
index bfa88f7c13ad4..2b3cd3a290197 100644
--- a/web/src/user_group_edit.js
+++ b/web/src/user_group_edit.js
@@ -23,7 +23,6 @@ import * as settings_components from "./settings_components";
 import * as settings_data from "./settings_data";
 import * as settings_org from "./settings_org";
 import {current_user, realm} from "./state_data";
-import * as stream_ui_updates from "./stream_ui_updates";
 import * as ui_report from "./ui_report";
 import * as user_group_components from "./user_group_components";
 import * as user_group_create from "./user_group_create";
@@ -96,7 +95,7 @@ function update_add_members_elements(group) {
         $button_element.prop("disabled", true);
         $add_members_container.addClass("add_members_disabled");
 
-        stream_ui_updates.initialize_disable_btn_hint_popover(
+        settings_components.initialize_disable_btn_hint_popover(
             $add_members_container,
             $t({defaultMessage: "Only group members can add users to a group."}),
         );
@@ -149,7 +148,7 @@ function initialize_tooltip_for_membership_button(group_id) {
     } else {
         tooltip_message = $t({defaultMessage: "You do not have permission to join this group."});
     }
-    stream_ui_updates.initialize_disable_btn_hint_popover($tooltip_wrapper, tooltip_message);
+    settings_components.initialize_disable_btn_hint_popover($tooltip_wrapper, tooltip_message);
 }
 
 function update_group_membership_button(group_id) {
diff --git a/web/src/user_profile.js b/web/src/user_profile.js
index 0390a3006d5d6..195c2af2b094b 100644
--- a/web/src/user_profile.js
+++ b/web/src/user_profile.js
@@ -30,12 +30,12 @@ import * as loading from "./loading";
 import * as modals from "./modals";
 import * as peer_data from "./peer_data";
 import * as people from "./people";
+import * as settings_components from "./settings_components";
 import * as settings_config from "./settings_config";
 import * as settings_data from "./settings_data";
 import * as settings_profile_fields from "./settings_profile_fields";
 import {current_user, realm} from "./state_data";
 import * as stream_data from "./stream_data";
-import * as stream_ui_updates from "./stream_ui_updates";
 import * as sub_store from "./sub_store";
 import * as subscriber_api from "./subscriber_api";
 import * as timerender from "./timerender";
@@ -142,7 +142,7 @@ function change_state_of_subscribe_button(event, dropdown) {
 
 function reset_subscribe_widget() {
     $("#user-profile-modal .add-subscription-button").prop("disabled", true);
-    stream_ui_updates.initialize_disable_btn_hint_popover(
+    settings_components.initialize_disable_btn_hint_popover(
         $("#user-profile-modal .add-subscription-button-wrapper"),
         $t({defaultMessage: "Select a stream to subscribe"}),
     );

Add clarification tooltip when settings can't be saved due to invalid Jitsi URL
In SETTINGS / ORGANIZATION SETTINGS > Other settings, we disable the "Save changes" button when the custom Jitsi URL is invalid. We should add a tooltip do the disabled button to explain why it is disabled: "Cannot save invalid Jitsi server URL."

<img width="809" alt="Screenshot 2023-11-02 at 10 31 14 PM" src="https://github.com/zulip/zulip/assets/2090066/b6bbb302-8b01-41ae-be98-1181497ecbf5">

