var ui = (function () { var exports = {}; var actively_scrolling = false; exports.have_scrolled_away_from_top = true; exports.actively_scrolling = function () { return actively_scrolling; }; // What, if anything, obscures the home tab? exports.home_tab_obscured = function () { if ($('.modal:visible').length > 0) { return 'modal'; } if (! $('#home').hasClass('active')) { return 'other_tab'; } return false; }; exports.change_tab_to = function (tabname) { $('#gear-menu a[href="' + tabname + '"]').tab('show'); }; exports.focus_on = function (field_id) { // Call after autocompleting on a field, to advance the focus to // the next input field. // Bootstrap's typeahead does not expose a callback for when an // autocomplete selection has been made, so we have to do this // manually. $("#" + field_id).focus(); }; exports.blur_active_element = function () { // this blurs anything that may perhaps be actively focused on. document.activeElement.blur(); }; function amount_to_paginate() { // Some day we might have separate versions of this function // for Page Up vs. Page Down, but for now it's the same // strategy in either direction. var info = viewport.message_viewport_info(); var page_size = info.visible_height; // We don't want to page up a full page, because Zulip users // are especially worried about missing messages, so we want // a little bit of the old page to stay on the screen. The // value chosen here is roughly 2 or 3 lines of text, but there // is nothing sacred about it, and somebody more anal than me // might wish to tie this to the size of some particular DOM // element. var overlap_amount = 55; var delta = page_size - overlap_amount; // If the user has shrunk their browser a whole lot, pagination // is not going to be very pleasant, but we can at least // ensure they go in the right direction. if (delta < 1) { delta = 1; } return delta; } exports.page_up_the_right_amount = function () { // This function's job is to scroll up the right amount, // after the user hits Page Up. We do this ourselves // because we can't rely on the browser to account for certain // page elements, like the compose box, that sit in fixed // positions above the message pane. For other scrolling // related adjustements, try to make those happen in the // scroll handlers, not here. var delta = amount_to_paginate(); viewport.scrollTop(viewport.scrollTop() - delta); }; exports.page_down_the_right_amount = function () { // see also: page_up_the_right_amount var delta = amount_to_paginate(); viewport.scrollTop(viewport.scrollTop() + delta); }; exports.replace_emoji_with_text = function (element) { element.find(".emoji").replaceWith(function () { return $(this).attr("alt"); }); }; /* We use 'visibility' rather than 'display' and jQuery's show() / hide(), because we want to reserve space for the email address. This avoids things jumping around slightly when the email address is shown. */ var current_message_hover; function message_unhover() { if (current_message_hover === undefined) { return; } current_message_hover.find('span.edit_content').html(""); current_message_hover.removeClass('message_hovered'); current_message_hover = undefined; } function message_hover(message_row) { var message; var id = parseInt(message_row.attr("zid"), 10); if (current_message_hover && message_row && current_message_hover.attr("zid") === message_row.attr("zid")) { return; } // Don't allow on-hover editing for local-only messages if (message_row.hasClass('local')) { return; } message = current_msg_list.get(rows.id(message_row)); message_unhover(); message_row.addClass('message_hovered'); current_message_hover = message_row; if (!message.sent_by_me) { // The actions and reactions icon hover logic is handled entirely by CSS return; } // But the message edit hover icon is determined by whether the message is still editablex if ((message_edit.get_editability(message) === message_edit.editability_types.FULL) && !message.status_message) { message_row.find(".edit_content").html(''); } else { message_row.find(".edit_content").html(''); } } /* Arguments used in the report_* functions are, response- response that we want to display status_box- element being used to display the response cls- class that we want to add/remove to/from the status_box type- used to define more complex logic for special cases (currently being used only for subscriptions-status) */ exports.report_message = function (response, status_box, cls, type) { if (cls === undefined) { cls = 'alert'; } if (type === undefined) { type = ' '; } if (type === 'subscriptions-status') { status_box.removeClass(status_classes).addClass(cls).children('#response') .text(response).stop(true).fadeTo(0, 1); } else { status_box.removeClass(status_classes).addClass(cls) .text(response).stop(true).fadeTo(0, 1); } status_box.show(); }; exports.report_error = function (response, xhr, status_box, type) { if (xhr && xhr.status.toString().charAt(0) === "4") { // Only display the error response for 4XX, where we've crafted // a nice response. response += ": " + JSON.parse(xhr.responseText).msg; } ui.report_message(response, status_box, 'alert-error', type); }; exports.report_success = function (response, status_box, type) { ui.report_message(response, status_box, 'alert-success', type); }; function update_message_in_all_views(message_id, callback) { _.each([message_list.all, home_msg_list, message_list.narrowed], function (list) { if (list === undefined) { return; } var row = list.get_row(message_id); if (row === undefined) { // The row may not exist, e.g. if you do an action on a message in // a narrowed view return; } callback(row); }); } exports.find_message = function (message_id) { // Try to find the message object. It might be in the narrow list // (if it was loaded when narrowed), or only in the message_list.all // (if received from the server while in a different narrow) var message; _.each([message_list.all, home_msg_list, message_list.narrowed], function (msg_list) { if (msg_list !== undefined && message === undefined) { message = msg_list.get(message_id); } }); return message; }; exports.update_starred = function (message_id, starred) { // Update the message object pointed to by the various message // lists. var message = exports.find_message(message_id); unread.mark_message_as_read(message); message.starred = starred; // Avoid a full re-render, but update the star in each message // table in which it is visible. update_message_in_all_views(message_id, function update_row(row) { var elt = row.find(".message_star"); if (starred) { elt.addClass("icon-vector-star").removeClass("icon-vector-star-empty").removeClass("empty-star"); } else { elt.removeClass("icon-vector-star").addClass("icon-vector-star-empty").addClass("empty-star"); } var title_state = message.starred ? "Unstar" : "Star"; elt.attr("title", title_state + " this message"); }); }; var local_messages_to_show = []; var show_message_timestamps = _.throttle(function () { _.each(local_messages_to_show, function (message_id) { update_message_in_all_views(message_id, function update_row(row) { row.find('.message_time').toggleClass('notvisible', false); }); }); local_messages_to_show = []; }, 100); exports.show_local_message_arrived = function (message_id) { local_messages_to_show.push(message_id); show_message_timestamps(); }; exports.show_message_failed = function (message_id, failed_msg) { // Failed to send message, so display inline retry/cancel update_message_in_all_views(message_id, function update_row(row) { var failed_div = row.find('.message_failed'); failed_div.toggleClass('notvisible', false); failed_div.find('.failed_text').attr('title', failed_msg); }); }; exports.show_failed_message_success = function (message_id) { // Previously failed message succeeded update_message_in_all_views(message_id, function update_row(row) { row.find('.message_failed').toggleClass('notvisible', true); }); }; exports.small_avatar_url = function (message) { // Try to call this function in all places where we need 25px // avatar images, so that the browser can help // us avoid unnecessary network trips. (For user-uploaded avatars, // the s=25 parameter is essentially ignored, but it's harmless.) // // We actually request these at s=50, so that we look better // on retina displays. if (message.avatar_url) { var url = message.avatar_url + "&s=50"; if (message.sent_by_me) { url += "&stamp=" + settings.avatar_stamp; } return url; } return ""; }; exports.lightbox = function (data) { switch (data.type) { case "photo": exports.lightbox_photo(data.image, data.user); break; case "youtube": exports.youtube_video(data.id); break; default: break; } $("#overlay").addClass("show"); popovers.hide_all(); }; exports.lightbox_photo = function (image, user) { // image should be an Image Object in JavaScript. var url = $(image).attr("src"); var title = $(image).parent().attr("title"); $("#overlay .player-container").hide(); $("#overlay .image-actions, .image-description, .download").show(); var img = new Image(); img.src = url; $("#overlay .image-preview").html("").show() .append(img); $(".image-description .title").text(title || "N/A"); $(".image-description .user").text(user); $(".image-actions .open, .image-actions .download").attr("href", url); }; exports.exit_lightbox_photo = function () { $("#overlay").removeClass("show"); $(".player-container iframe").remove(); document.activeElement.blur(); }; exports.youtube_video = function (id) { $("#overlay .image-preview, .image-description, .download").hide(); var iframe = document.createElement("iframe"); iframe.width = window.innerWidth; iframe.height = window.innerWidth * 0.5625; iframe.src = "https://www.youtube.com/embed/" + id; iframe.setAttribute("frameborder", 0); iframe.setAttribute("allowfullscreen", true); $("#overlay .player-container").html("").show().append(iframe); $(".image-actions .open").attr("href", "https://youtu.be/" + id); }; // k3O01EfM5fU var loading_more_messages_indicator_showing = false; exports.show_loading_more_messages_indicator = function () { if (! loading_more_messages_indicator_showing) { loading.make_indicator($('#loading_more_messages_indicator'), {abs_positioned: true}); loading_more_messages_indicator_showing = true; floating_recipient_bar.hide(); } }; exports.hide_loading_more_messages_indicator = function () { if (loading_more_messages_indicator_showing) { loading.destroy_indicator($("#loading_more_messages_indicator")); loading_more_messages_indicator_showing = false; } }; /* EXPERIMENTS */ /* This method allows an advanced user to use the console * to switch the application to span full width of the browser. */ exports.switchToFullWidth = function () { $("#full-width-style").remove(); $('head').append(''); return ("Switched to full width"); }; /* END OF EXPERIMENTS */ $(function () { var throttled_mousewheelhandler = $.throttle(50, function (e, delta) { // Most of the mouse wheel's work will be handled by the // scroll handler, but when we're at the top or bottom of the // page, the pointer may still need to move. if (delta > 0) { if (viewport.at_top()) { navigate.up(); } } else if (delta < 0) { if (viewport.at_bottom()) { navigate.down(); } } viewport.last_movement_direction = delta; }); viewport.message_pane.mousewheel(function (e, delta) { // Ignore mousewheel events if a modal is visible. It's weird if the // user can scroll the main view by wheeling over the greyed-out area. // Similarly, ignore events on settings page etc. // // We don't handle the compose box here, because it *should* work to // select the compose box and then wheel over the message stream. var obscured = exports.home_tab_obscured(); if (!obscured) { throttled_mousewheelhandler(e, delta); } else if (obscured === 'modal') { // The modal itself has a handler invoked before this one (see below). // preventDefault here so that the tab behind the modal doesn't scroll. // // This needs to include the events that would be ignored by throttling. // That's why this code can't be moved into throttled_mousewheelhandler. e.preventDefault(); } // If on another tab, we neither handle the event nor preventDefault, allowing // the tab to scroll normally. }); $(window).resize($.throttle(50, resize.handler)); // Scrolling in modals, input boxes, and other elements that // explicitly scroll should not scroll the main view. Stop // propagation in all cases. Also, ignore the event if the // element is already at the top or bottom. Otherwise we get a // new scroll event on the parent (?). $('.modal-body, .scrolling_list, input, textarea').mousewheel(function (e, delta) { var self = $(this); var scroll = self.scrollTop(); // The -1 fudge factor is important here due to rounding errors. Better // to err on the side of not scrolling. var max_scroll = this.scrollHeight - self.innerHeight() - 1; e.stopPropagation(); if ( ((delta > 0) && (scroll <= 0)) || ((delta < 0) && (scroll >= max_scroll))) { e.preventDefault(); } }); // Override the #compose mousewheel prevention below just for the emoji box $('.emoji_popover').mousewheel(function (e) { e.stopPropagation(); }); // Ignore wheel events in the compose area which weren't already handled above. $('#compose').mousewheel(function (e) { e.stopPropagation(); e.preventDefault(); }); // A little hackish, because it doesn't seem to totally get us the // exact right width for the floating_recipient_bar and compose // box, but, close enough for now. resize.handler(); if (!page_params.left_side_userlist) { $("#navbar-buttons").addClass("right-userlist"); } $("#main_div").on("mouseover", ".message_row", function () { var row = $(this).closest(".message_row"); message_hover(row); }); $("#main_div").on("mouseleave", ".message_row", function () { message_unhover(); }); $("#main_div").on("mouseover", ".message_sender", function () { var row = $(this).closest(".message_row"); row.addClass("sender_name_hovered"); }); $("#main_div").on("mouseout", ".message_sender", function () { var row = $(this).closest(".message_row"); row.removeClass("sender_name_hovered"); }); $("#subscriptions_table").on("mouseover", ".subscription_header", function () { $(this).addClass("active"); }); $("#subscriptions_table").on("mouseout", ".subscription_header", function () { $(this).removeClass("active"); }); $("#stream").on('blur', function () { compose.decorate_stream_bar(this.value); }); $(window).on('blur', function () { $(document.body).addClass('window_blurred'); }); $(window).on('focus', function () { $(document.body).removeClass('window_blurred'); }); $(document).on('message_selected.zulip', function (event) { if (current_msg_list !== event.msg_list) { return; } if (event.id === -1) { // If the message list is empty, don't do anything return; } var row = event.msg_list.get_row(event.id); $('.selected_message').removeClass('selected_message'); row.addClass('selected_message'); if (event.then_scroll) { if (row.length === 0) { var row_from_dom = current_msg_list.get_row(event.id); blueslip.debug("message_selected missing selected row", { previously_selected: event.previously_selected, selected_id: event.id, selected_idx: event.msg_list.selected_idx(), selected_idx_exact: event.msg_list._items.indexOf(event.msg_list.get(event.id)), render_start: event.msg_list.view._render_win_start, render_end: event.msg_list.view._render_win_end, selected_id_from_idx: event.msg_list._items[event.msg_list.selected_idx()].id, msg_list_sorted: _.isEqual( _.pluck(event.msg_list._items, 'id'), _.chain(current_msg_list._items).pluck('id').clone().value().sort() ), found_in_dom: row_from_dom.length }); } if (event.target_scroll_offset !== undefined) { viewport.set_message_offset(event.target_scroll_offset); } else { // Scroll to place the message within the current view; // but if this is the initial placement of the pointer, // just place it in the very center viewport.recenter_view(row, {from_scroll: event.from_scroll, force_center: event.previously_selected === -1}); } } }); $("#main_div").on("mouseenter", ".message_time", function (e) { var time_elem = $(e.target); var row = time_elem.closest(".message_row"); var message = current_msg_list.get(rows.id(row)); timerender.set_full_datetime(message, time_elem); }); $('#streams_header h4').tooltip({ placement: 'right', animation: false }); $('#streams_header i[data-toggle="tooltip"]').tooltip({ placement: 'left', animation: false }); $('.message_failed i[data-toggle="tooltip"]').tooltip(); if (!page_params.realm_allow_message_editing) { $("#edit-message-hotkey-help").hide(); } if (page_params.presence_disabled) { $("#user-list").hide(); $("#group-pm-list").hide(); } if (feature_flags.full_width) { exports.switchToFullWidth(); } // initialize other stuff reload.initialize(); composebox_typeahead.initialize(); search.initialize(); tutorial.initialize(); notifications.initialize(); gear_menu.initialize(); hashchange.initialize(); invite.initialize(); pointer.initialize(); unread.initialize(); activity.initialize(); emoji.initialize(); }); function scroll_finished() { actively_scrolling = false; if ($('#home').hasClass('active')) { if (!pointer.suppress_scroll_pointer_update) { pointer.keep_pointer_in_view(); } else { pointer.suppress_scroll_pointer_update = false; } floating_recipient_bar.update(); if (viewport.scrollTop() === 0 && ui.have_scrolled_away_from_top) { ui.have_scrolled_away_from_top = false; message_store.load_more_messages(current_msg_list); } else if (!ui.have_scrolled_away_from_top) { ui.have_scrolled_away_from_top = true; } // When the window scrolls, it may cause some messages to // enter the screen and become read. Calling // unread.process_visible will update necessary // data structures and DOM elements. setTimeout(unread.process_visible, 0); } } var scroll_timer; function scroll_finish() { actively_scrolling = true; clearTimeout(scroll_timer); scroll_timer = setTimeout(scroll_finished, 100); } // Save the compose content cursor position and restore when we // shift-tab back in (see hotkey.js). var saved_compose_cursor = 0; $(function () { viewport.message_pane.scroll($.throttle(50, function () { unread.process_visible(); scroll_finish(); })); $('#new_message_content').blur(function () { saved_compose_cursor = $(this).caret(); }); }); exports.restore_compose_cursor = function () { $('#new_message_content') .focus() .caret(saved_compose_cursor); }; $(function () { if (window.bridge !== undefined) { // Disable "spellchecking" in our desktop app. The "spellchecking" // in our Mac app is actually autocorrect, and frustrates our // users. $("#new_message_content").attr('spellcheck', 'false'); // Modify the zephyr mirroring error message in our desktop // app, since it doesn't work from the desktop version. $("#webathena_login_menu").hide(); $("#normal-zephyr-mirror-error-text").addClass("notdisplayed"); $("#desktop-zephyr-mirror-error-text").removeClass("notdisplayed"); } }); return exports; }()); if (typeof module !== 'undefined') { module.exports = ui; }