zulip/static/js/ui.js

639 lines
22 KiB
JavaScript
Raw Normal View History

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');
if ((message_edit.get_editability(message) === message_edit.editability_types.FULL) &&
!message.status_message) {
message_row.find(".edit_content").html('<i class="icon-vector-pencil edit_content_button"></i>');
} else {
Add frontend support for emoji reactions. This commit replaces the placeholder "clipboard" button with a reaction button. This is done on any message that can't be edited. Also, on messages sent by the user the actions popover (toggled by the down chevron icon) contains an option to add a reaction. When clicked, a popover with a search bar and a list of emojis is displayed. If the right sidebar is collapsed (the viewport is small), the popover is placed to the left of the button. Focus is set to the search bar. Typing in the search bar filters emojis. Emojis with which the user has reacted to this message are highlighted. Clicking them sends an API request to remove that reaction. Clicking on non-highlighted emojis sends an API request to add a reaction. When the popover loses focus it is closed. The frontend listens for reaction events. When an add-reaction event is received, the emoji is displayed at the bottom of the message with a count initialized to 1. If there was an existing reaction to the message with the same emoji, the count is incremented. Old messages fetched from the server contain reactions. They are displayed (along with title and count) at the bottom of each message. When clicking the emoji reaction at the bottom of the message, if the user has already reacted with that emoji to this message, the reaction is removed and the count is decremented. Otherwise, a reaction is added and the count is incremented. Hovering over the emoji reaction at the bottom of the message displays a list of users who have reacted with this emoji along with the emoji name. Hovering over the emoji reactions at the bottom of the message displays a button to add a reaction. Fixes #541.
2016-12-02 13:23:23 +01:00
message_row.find(".edit_content").html('<i class="icon-vector-smile reaction_button" data-msgid="' + id + '"></i>');
}
current_message_hover = message_row;
}
/* 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
2016-04-21 22:49:23 +02:00
// (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");
};
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('<style id="full-width-style" type="text/css">' +
'#home .alert-bar, .recipient-bar-content, #compose-container, .app-main, .header-main { max-width: none; }' +
'</style>');
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
2016-05-25 13:42:20 +02:00
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;
}());
2016-12-29 12:23:07 +01:00
if (typeof module !== 'undefined') {
module.exports = ui;
}