/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/hashchange.ts
622 строки
23 KB
Anders Kaseorg
eslint: Fix unicorn/prefer-else-if.
29 июл 2026, 02:35
29 июл 2026, 02:35
1418047
Код
Авторство
О чём код?
import {$} from "jquery"; import assert from "minimalistic-assert"; import * as z from "zod/mini"; import * as about_zulip from "./about_zulip.ts"; import * as admin from "./admin.ts"; import * as blueslip from "./blueslip.ts"; import * as browser_history from "./browser_history.ts"; import * as drafts_overlay_ui from "./drafts_overlay_ui.ts"; import {Filter} from "./filter.ts"; import * as hash_parser from "./hash_parser.ts"; import * as hash_util from "./hash_util.ts"; import {$t_html} from "./i18n.ts"; import * as inbox_ui from "./inbox_ui.ts"; import * as info_overlay from "./info_overlay.ts"; import * as message_fetch from "./message_fetch.ts"; import * as message_view from "./message_view.ts"; import * as message_viewport from "./message_viewport.ts"; import * as modals from "./modals.ts"; import * as overlays from "./overlays.ts"; import {page_params} from "./page_params.ts"; import * as people from "./people.ts"; import * as popovers from "./popovers.ts"; import * as recent_view_ui from "./recent_view_ui.ts"; import * as reminders_overlay_ui from "./reminders_overlay_ui.ts"; import * as scheduled_messages_overlay_ui from "./scheduled_messages_overlay_ui.ts"; import * as settings from "./settings.ts"; import * as settings_panel_menu from "./settings_panel_menu.ts"; import * as settings_toggle from "./settings_toggle.ts"; import * as sidebar_ui from "./sidebar_ui.ts"; import * as spectators from "./spectators.ts"; import {current_user} from "./state_data.ts"; import * as stream_settings_ui from "./stream_settings_ui.ts"; import * as ui_report from "./ui_report.ts"; import * as user_group_edit from "./user_group_edit.ts"; import * as user_profile from "./user_profile.ts"; import {user_settings} from "./user_settings.ts"; import * as util from "./util.ts"; // Read https://zulip.readthedocs.io/en/latest/subsystems/hashchange-system.html // or locally: docs/subsystems/hashchange-system.md export type JQueryHashChangeEvent<TDelegateTarget, TData, TCurrentTarget, TTarget> = JQuery.EventBase<TDelegateTarget, TData, TCurrentTarget, TTarget> & { type: "hashchanged"; originalEvent: HashChangeEvent; }; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JQuery { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface TypeToTriggeredEventMap<TDelegateTarget, TData, TCurrentTarget, TTarget> { hashchange: JQueryHashChangeEvent<TDelegateTarget, TData, TCurrentTarget, TTarget>; } } } function show_all_message_view(narrow_opts?: message_view.ShowMessageViewOpts): void { // Don't export this function outside of this module since // `change_hash` is false here which means it is should only // be called after hash is updated in the URL. message_view.show([{operator: "in", operand: "home"}], { trigger: "hashchange", change_hash: false, ...narrow_opts, }); } function is_somebody_else_profile_open(): boolean { return ( user_profile.get_user_id_if_user_profile_modal_open() !== undefined && user_profile.get_user_id_if_user_profile_modal_open() !== people.my_current_user_id() ); } // The first slot of a channels-settings hash is overloaded: // - "subscribed" / "available" / "all" → left-panel tab // - "new" → create-channel form // - a stream-id string → that channel's settings // Disentangle it into the right-panel state and the left-side tab key // that stream_settings_ui expects. function channels_overlay_state_from_hash(): { right_panel: "new" | number | undefined; left_side_tab: string | undefined; } { const hash_section = hash_parser.get_current_hash_section(); if (hash_section === "new") { return {right_panel: "new", left_side_tab: undefined}; } if (util.is_numeric_string(hash_section)) { return {right_panel: Number.parseInt(hash_section, 10), left_side_tab: undefined}; } // validate_channels_settings_hash has already constrained // hash_section, so anything else is one of "subscribed" / // "available" / "all". return {right_panel: undefined, left_side_tab: hash_section}; } function get_settings_tab(section: string): string | undefined { if (section === "users" || section === "bots") { const current_settings_tab = hash_parser.get_current_nth_hash_section(2); // URL will be updated in hash_util.validate_settings_hash to contain // the correct tab value. return current_settings_tab; } return undefined; } export function set_hash_to_home_view(triggered_by_escape_key = false): void { if (browser_history.is_current_hash_home_view()) { return; } const hash_before_current = browser_history.old_hash(); if ( triggered_by_escape_key && browser_history.get_home_view_hash() === "#feed" && (hash_before_current === "" || hash_before_current === "#feed") ) { // If the previous view was the user's Combined Feed home // view, and this change was triggered by escape keypress, // then we simulate the back button in order to reuse // existing code for restoring the user's scroll position. window.history.back(); return; } // We want to set URL with no hash here. It is not possible // to do so with `window.location.hash` since it will set an empty // hash. So, we use `pushState` which simply updates the current URL // but doesn't trigger `hashchange`. So, we trigger hashchange directly // here to let it handle the whole rendering process for us. browser_history.set_hash(""); hashchanged(false); } function show_home_view(narrow_opts?: message_view.ShowMessageViewOpts): void { // This function should only be called from the hashchange // handlers, as it does not set the hash to "". // // We only allow the primary recommended options for home views // rendered without a hash. switch (user_settings.web_home_view) { case "recent": { recent_view_ui.show(); break; } case "all_messages": { // Hides inbox/recent views internally if open. show_all_message_view(narrow_opts); break; } case "inbox": { inbox_ui.show(); break; } default: { // NOTE: Setting a hash which is not rendered on // empty hash (like a stream narrow) will // introduce a bug that user will not be able to // go back in browser history. See // https://chat.zulip.org/#narrow/channel/9-issues/topic/Browser.20back.20button.20on.20RT // for detailed description of the issue. window.location.hash = user_settings.web_home_view; } } } // Returns true if this function performed a narrow function do_hashchange_normal(from_reload: boolean, restore_selected_id: boolean): boolean { message_viewport.stop_auto_scrolling(); // NB: In Firefox, window.location.hash is URI-decoded. // Even if the URL bar says #%41%42%43%44, the value here will // be #ABCD. const hash = window.location.hash.split("/"); const narrow_opts: message_view.ShowMessageViewOpts = { change_hash: false, // already set trigger: "hash change", show_more_topics: false, }; if (from_reload) { blueslip.debug("We are narrowing as part of a reload."); if (message_fetch.initial_narrow_pointer !== undefined) { narrow_opts.then_select_id = message_fetch.initial_narrow_pointer; narrow_opts.then_select_offset = message_fetch.initial_narrow_offset; } } const data_for_hash = z.nullable(browser_history.state_data_schema).parse(window.history.state); if (restore_selected_id && data_for_hash) { narrow_opts.then_select_id = data_for_hash.narrow_pointer; narrow_opts.then_select_offset = data_for_hash.narrow_offset; narrow_opts.show_more_topics = data_for_hash.show_more_topics ?? false; } switch (hash[0]) { case "#topics": case "#narrow": { let terms; try { // TODO: Show possible valid URLs to the user. terms = hash_util.parse_narrow(hash); } catch { ui_report.error( $t_html({defaultMessage: "Invalid URL"}), undefined, $("#home-error"), 2000, ); } if (terms === undefined) { // If the narrow URL didn't parse, // send them to web_home_view. // We cannot clear hash here since // it will block user from going back // in browser history. show_home_view(); return false; } // Show inbox style topics list for #topics narrow. if (hash[0] === "#topics" && terms.length === 1) { const channel_id_string = hash[2]; if (channel_id_string) { inbox_ui.show(new Filter(terms)); return true; } } message_view.show(terms, narrow_opts); return true; } case "": case "#": show_home_view(narrow_opts); break; case "#recent_topics": // The URL for Recent Conversations was changed from // #recent_topics to #recent in 2022. Because pre-change // Welcome Bot messages included links to this URL, we // need to support the "#recent_topics" hash as an alias // for #recent permanently. We show the view and then // replace the current URL hash in a way designed to hide // this detail in the browser's forward/back session history. recent_view_ui.show(); window.location.replace("#recent"); break; case "#recent": recent_view_ui.show(); break; case "#inbox": inbox_ui.show(); break; case "#all_messages": // "#all_messages" was renamed to "#feed" in 2024. Unlike // the recent hash rename, there are likely few links that // would break if this compatibility code was removed, but // there's little cost to keeping it. show_all_message_view(narrow_opts); window.location.replace("#feed"); break; case "#feed": show_all_message_view(narrow_opts); break; case "#keyboard-shortcuts": case "#message-formatting": case "#search-operators": case "#drafts": case "#invite": case "#channels": case "#streams": case "#organization": case "#settings": case "#about-zulip": case "#scheduled": case "#reminders": blueslip.error("overlay logic skipped for: " + hash[0]); break; default: show_home_view(narrow_opts); } return false; } function do_hashchange_overlay(old_hash: string | undefined): void { if (old_hash === undefined) { // The user opened the app with an overlay hash; we need to // show the user's home view behind it. show_home_view(); } let base = hash_parser.get_current_hash_category(); const old_base = hash_parser.get_hash_category(old_hash); let section = hash_parser.get_current_hash_section(); if (base === "groups" && current_user.is_guest) { // The #groups settings page is unfinished, and disabled in production. show_home_view(); return; } const coming_from_overlay = hash_parser.is_overlay_hash(old_hash); switch (base) { case "settings": case "organization": { let settings_panel_object = settings_panel_menu.normal_settings; if (base === "organization") { settings_panel_object = settings_panel_menu.org_settings; } const valid_hash = hash_util.validate_settings_hash( window.location.hash, settings_panel_object, ); if (valid_hash !== window.location.hash) { window.history.replaceState(null, "", browser_history.get_full_url(valid_hash)); section = hash_parser.get_hash_section(valid_hash); } break; } case "streams": case "channels": { const valid_hash = hash_util.validate_channels_settings_hash(window.location.hash); // Here valid_hash will always return "channels" as the base. // So, if we update the history because the valid hash does // not match the window.location.hash, then we also reset the // base string we're tracking for the hash. if (valid_hash !== window.location.hash) { window.history.replaceState(null, "", browser_history.get_full_url(valid_hash)); section = hash_parser.get_current_hash_section(); base = hash_parser.get_current_hash_category(); } break; } case "groups": { const valid_hash = hash_util.validate_group_settings_hash(window.location.hash); if (valid_hash !== window.location.hash) { window.history.replaceState(null, "", browser_history.get_full_url(valid_hash)); section = hash_parser.get_current_hash_section(); } break; } } // Start by handling the specific case of going from // something like "#channels/all" to "#channels/subscribed". // // In most situations we skip by this logic and load // the new overlay. if (coming_from_overlay && base === old_base) { switch (base) { case "channels": { if (hash_parser.get_current_nth_hash_section(1) === "folders") { const folder_id = hash_parser.get_current_nth_hash_section(2); // Folder-create: show create form; left tab unchanged. stream_settings_ui.change_state( "new", undefined, undefined, Number.parseInt(folder_id, 10), ); return; } // e.g. #channels/29/social/subscribers const {right_panel, left_side_tab} = channels_overlay_state_from_hash(); const right_side_tab = hash_parser.get_current_nth_hash_section(3); stream_settings_ui.change_state(right_panel, left_side_tab, right_side_tab); return; } case "groups": { const right_side_tab = hash_parser.get_current_nth_hash_section(3); user_group_edit.change_state(section, undefined, right_side_tab); break; } case "settings": { if (!section) { // We may be on a really old browser or somebody // hand-typed a hash. blueslip.warn("missing section for settings"); } settings_panel_menu.normal_settings.activate_section_or_default( section, get_settings_tab(section), ); return; } case "organization": { if (!section) { // We may be on a really old browser or somebody // hand-typed a hash. blueslip.warn("missing section for organization"); } settings_panel_menu.org_settings.activate_section_or_default( section, get_settings_tab(section), ); return; } } // TODO: handle other cases like internal settings // changes. return; } // This is a special case when user clicks on a URL that makes the overlay switch // from org settings to user settings or user edits the URL to switch between them. const settings_hashes = new Set(["settings", "organization"]); // Ensure that we are just switching between user and org settings and the settings // overlay is open. const is_hashchange_internal = settings_hashes.has(base) && settings_hashes.has(old_base) && overlays.settings_open(); if (is_hashchange_internal) { if (base === "settings") { settings_panel_menu.normal_settings.set_current_tab(section); if (section === "bots") { settings_panel_menu.normal_settings.set_bot_settings_tab( get_settings_tab(section)!, "personal", ); } } else { settings_panel_menu.org_settings.set_current_tab(section); if (section === "users") { settings_panel_menu.org_settings.set_user_settings_tab(get_settings_tab(section)); } else if (section === "bots") { settings_panel_menu.org_settings.set_bot_settings_tab( get_settings_tab(section)!, "org", ); } } settings_toggle.goto(base); return; } // It's not super likely that an overlay is already open, // but you can jump from /settings to /streams by using // the browser's history menu or hand-editing the URL or // whatever. If so, just close the overlays. if (base !== old_base) { overlays.close_for_hash_change(); } // NORMAL FLOW: basically, launch the overlay: if (!coming_from_overlay) { browser_history.set_hash_before_overlay(old_hash); } if (base === "channels") { if (hash_parser.get_current_nth_hash_section(1) === "folders") { const folder_id = hash_parser.get_current_nth_hash_section(2); stream_settings_ui.launch("new", undefined, undefined, Number.parseInt(folder_id, 10)); return; } // e.g. #channels/29/social/subscribers const {right_panel, left_side_tab} = channels_overlay_state_from_hash(); const right_side_tab = hash_parser.get_current_nth_hash_section(3); // When somebody else's profile is open and we're navigating to // a specific channel, default the left tab to "All" instead of // inferring from subscription. URLs with a channel ID never // carry an explicit left tab, so this overrides nothing. if (is_somebody_else_profile_open() && typeof right_panel === "number") { assert(left_side_tab === undefined); stream_settings_ui.launch(right_panel, "all", right_side_tab); return; } stream_settings_ui.launch(right_panel, left_side_tab, right_side_tab); return; } if (base === "groups") { const right_side_tab = hash_parser.get_current_nth_hash_section(3); if (is_somebody_else_profile_open()) { user_group_edit.launch(section, "all-groups", right_side_tab); return; } // We pass left_side_tab as undefined in change_state to // select the tab based on user's membership. "My groups" is // selected if user is a member of group being edited. // Otherwise "All groups" is selected. user_group_edit.launch(section, undefined, right_side_tab); return; } if (base === "drafts") { drafts_overlay_ui.launch(); return; } if (base === "settings") { settings.build_page(); admin.build_page(); settings.launch(section, get_settings_tab(section)); return; } if (base === "organization") { settings.build_page(); admin.build_page(); admin.launch(section, get_settings_tab(section)); return; } if (base === "keyboard-shortcuts") { info_overlay.show("keyboard-shortcuts"); return; } if (base === "message-formatting") { info_overlay.show("message-formatting"); return; } if (base === "search-operators") { info_overlay.show("search-operators"); return; } if (base === "about-zulip") { about_zulip.launch(); return; } if (base === "scheduled") { scheduled_messages_overlay_ui.launch(); return; } if (base === "reminders") { reminders_overlay_ui.launch(); return; } if (base === "user") { const user_id = Number.parseInt(hash_parser.get_current_hash_section(), 10); if (!people.is_known_user_id(user_id)) { user_profile.show_user_profile_access_error_modal(); } else { user_profile.show_user_profile(user_id); } return; } } function hashchanged( from_reload: boolean, e?: JQuery.Event | HashChangeEvent, restore_selected_id = true, ): boolean | undefined { const current_hash = window.location.hash; const old_hash = e && ("oldURL" in e ? new URL(e.oldURL).hash : browser_history.old_hash()); const is_hash_web_public_compatible = browser_history.update_web_public_hash(current_hash); const was_internal_change = browser_history.save_old_hash(); if (was_internal_change) { return undefined; } // TODO: Migrate the `#reload` syntax to use slashes as separators // so that this can be part of the main switch statement. if (window.location.hash.startsWith("#reload")) { // We don't want to change narrow if app is undergoing reload. return undefined; } if (page_params.is_spectator && !is_hash_web_public_compatible) { spectators.login_to_access(); return undefined; } popovers.hide_all(); if (hash_parser.is_overlay_hash(current_hash)) { browser_history.state.changing_hash = true; modals.close_active_if_any(); do_hashchange_overlay(old_hash); browser_history.state.changing_hash = false; return undefined; } // We are changing to a "main screen" view. overlays.close_for_hash_change(); sidebar_ui.hide_all(); modals.close_active_if_any(); browser_history.state.changing_hash = true; const ret = do_hashchange_normal(from_reload, restore_selected_id); browser_history.state.changing_hash = false; return ret; } export function initialize(): void { // We don't want browser to restore the scroll // position of the new hash in the current hash. window.history.scrollRestoration = "manual"; $(window).on("hashchange", (e) => { hashchanged(false, e.originalEvent); }); hashchanged(true); $("body").on("click", "a", function (this: HTMLAnchorElement, e: JQuery.ClickEvent) { if (this.hash === window.location.hash && this.hash.includes("/near/")) { // User clicked on a link, perhaps a "said" reference, that // matches the current view. Such a click doesn't trigger // a hashchange event, so we manually trigger one in order // to ensure the app scrolls to the correct message. hashchanged(false, e, false); } }); }