mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 22:13:26 +00:00
We now treat util like a leaf module and use "require" to import it everywhere it's used. An earlier version of this commit moved util into our "shared" library, but we decided to wait on that. Once we're ready to do that, we should only need to do a simple search/replace on various require/zrequire statements plus a small tweak to one of the custom linter checks. It turns out we don't really need util.js for our most immediate code-sharing goal, which is to reuse our markdown code on mobile. There's a little bit of cleanup still remaining to break the dependency, but it's minor. The util module still calls the global blueslip module in one place, but that code is about to be removed in the next few commits. I am pretty confident that once we start sharing things like the typeahead code more aggressively, we'll start having dependencies on util. The module is barely more than 300 lines long, so we'll probably just move the whole thing into shared rather than break it apart. Also, we can continue to nibble away at the cruftier parts of the module.
509 lines
16 KiB
JavaScript
509 lines
16 KiB
JavaScript
const util = require("./util");
|
|
const render_draft_table_body = require('../templates/draft_table_body.hbs');
|
|
|
|
const draft_model = (function () {
|
|
const exports = {};
|
|
|
|
// the key that the drafts are stored under.
|
|
const KEY = "drafts";
|
|
const ls = localstorage();
|
|
ls.version = 1;
|
|
|
|
function getTimestamp() {
|
|
return new Date().getTime();
|
|
}
|
|
|
|
function get() {
|
|
return ls.get(KEY) || {};
|
|
}
|
|
exports.get = get;
|
|
|
|
exports.getDraft = function (id) {
|
|
return get()[id] || false;
|
|
};
|
|
|
|
function save(drafts) {
|
|
ls.set(KEY, drafts);
|
|
}
|
|
|
|
exports.addDraft = function (draft) {
|
|
const drafts = get();
|
|
|
|
// use the base16 of the current time + a random string to reduce
|
|
// collisions to essentially zero.
|
|
const id = getTimestamp().toString(16) + "-" + Math.random().toString(16).split(/\./).pop();
|
|
|
|
draft.updatedAt = getTimestamp();
|
|
drafts[id] = draft;
|
|
save(drafts);
|
|
|
|
return id;
|
|
};
|
|
|
|
exports.editDraft = function (id, draft) {
|
|
const drafts = get();
|
|
|
|
if (drafts[id]) {
|
|
draft.updatedAt = getTimestamp();
|
|
drafts[id] = draft;
|
|
save(drafts);
|
|
}
|
|
};
|
|
|
|
exports.deleteDraft = function (id) {
|
|
const drafts = get();
|
|
|
|
delete drafts[id];
|
|
save(drafts);
|
|
};
|
|
|
|
return exports;
|
|
}());
|
|
|
|
exports.draft_model = draft_model;
|
|
|
|
exports.snapshot_message = function () {
|
|
if (!compose_state.composing() || compose_state.message_content().length <= 2) {
|
|
// If you aren't in the middle of composing the body of a
|
|
// message or the message is shorter than 2 characters long, don't try to snapshot.
|
|
return;
|
|
}
|
|
|
|
// Save what we can.
|
|
const message = {
|
|
type: compose_state.get_message_type(),
|
|
content: compose_state.message_content(),
|
|
};
|
|
if (message.type === "private") {
|
|
const recipient = compose_state.private_message_recipient();
|
|
message.reply_to = recipient;
|
|
message.private_message_recipient = recipient;
|
|
} else {
|
|
message.stream = compose_state.stream_name();
|
|
message.topic = compose_state.topic();
|
|
}
|
|
return message;
|
|
};
|
|
|
|
exports.restore_message = function (draft) {
|
|
// This is kinda the inverse of snapshot_message, and
|
|
// we are essentially making a deep copy of the draft,
|
|
// being explicit about which fields we send to the compose
|
|
// system.
|
|
let compose_args;
|
|
|
|
if (draft.type === "stream") {
|
|
compose_args = {
|
|
type: 'stream',
|
|
stream: draft.stream,
|
|
topic: util.get_draft_topic(draft),
|
|
content: draft.content,
|
|
};
|
|
|
|
} else {
|
|
compose_args = {
|
|
type: draft.type,
|
|
private_message_recipient: draft.private_message_recipient,
|
|
content: draft.content,
|
|
};
|
|
}
|
|
|
|
return compose_args;
|
|
};
|
|
|
|
function draft_notify() {
|
|
$(".alert-draft").css("display", "inline-block");
|
|
$(".alert-draft").delay(1000).fadeOut(300);
|
|
}
|
|
|
|
exports.update_draft = function () {
|
|
const draft = exports.snapshot_message();
|
|
const draft_id = $("#compose-textarea").data("draft-id");
|
|
|
|
if (draft_id !== undefined) {
|
|
if (draft !== undefined) {
|
|
draft_model.editDraft(draft_id, draft);
|
|
draft_notify();
|
|
} else {
|
|
draft_model.deleteDraft(draft_id);
|
|
}
|
|
} else {
|
|
if (draft !== undefined) {
|
|
const new_draft_id = draft_model.addDraft(draft);
|
|
$("#compose-textarea").data("draft-id", new_draft_id);
|
|
draft_notify();
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.delete_draft_after_send = function () {
|
|
const draft_id = $("#compose-textarea").data("draft-id");
|
|
if (draft_id) {
|
|
draft_model.deleteDraft(draft_id);
|
|
}
|
|
$("#compose-textarea").removeData("draft-id");
|
|
};
|
|
|
|
exports.restore_draft = function (draft_id) {
|
|
const draft = draft_model.getDraft(draft_id);
|
|
if (!draft) {
|
|
return;
|
|
}
|
|
|
|
const compose_args = exports.restore_message(draft);
|
|
|
|
if (compose_args.type === "stream") {
|
|
if (draft.stream !== "") {
|
|
narrow.activate(
|
|
[
|
|
{operator: "stream", operand: compose_args.stream},
|
|
{operator: "topic", operand: compose_args.topic},
|
|
],
|
|
{trigger: "restore draft"}
|
|
);
|
|
}
|
|
} else {
|
|
if (compose_args.private_message_recipient !== "") {
|
|
narrow.activate(
|
|
[
|
|
{operator: "pm-with", operand: compose_args.private_message_recipient},
|
|
],
|
|
{trigger: "restore draft"}
|
|
);
|
|
}
|
|
}
|
|
|
|
overlays.close_overlay("drafts");
|
|
compose_fade.clear_compose();
|
|
compose.clear_preview_area();
|
|
|
|
if (draft.type === "stream" && draft.stream === "") {
|
|
compose_args.topic = "";
|
|
}
|
|
compose_actions.start(compose_args.type, compose_args);
|
|
compose_ui.autosize_textarea();
|
|
$("#compose-textarea").data("draft-id", draft_id);
|
|
};
|
|
|
|
const DRAFT_LIFETIME = 30;
|
|
|
|
exports.remove_old_drafts = function () {
|
|
const old_date = new Date().setDate(new Date().getDate() - DRAFT_LIFETIME);
|
|
const drafts = draft_model.get();
|
|
for (const [id, draft] of Object.entries(drafts)) {
|
|
if (draft.updatedAt < old_date) {
|
|
draft_model.deleteDraft(id);
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.format_draft = function (draft) {
|
|
const id = draft.id;
|
|
let formatted;
|
|
const time = new XDate(draft.updatedAt);
|
|
let time_stamp = timerender.render_now(time).time_str;
|
|
if (time_stamp === i18n.t("Today")) {
|
|
time_stamp = timerender.stringify_time(time);
|
|
}
|
|
if (draft.type === "stream") {
|
|
// In case there is no stream for the draft, we need a
|
|
// single space char for proper rendering of the stream label
|
|
const space_string = new Handlebars.SafeString(" ");
|
|
const stream = draft.stream.length > 0 ? draft.stream : space_string;
|
|
let draft_topic = util.get_draft_topic(draft);
|
|
const draft_stream_color = stream_data.get_color(draft.stream);
|
|
|
|
if (draft_topic === '') {
|
|
draft_topic = compose.empty_topic_placeholder();
|
|
}
|
|
|
|
formatted = {
|
|
draft_id: draft.id,
|
|
is_stream: true,
|
|
stream: stream,
|
|
stream_color: draft_stream_color,
|
|
dark_background: stream_color.get_color_class(draft_stream_color),
|
|
topic: draft_topic,
|
|
raw_content: draft.content,
|
|
time_stamp: time_stamp,
|
|
};
|
|
} else {
|
|
const emails = util.extract_pm_recipients(draft.private_message_recipient);
|
|
const recipients = emails.map(email => {
|
|
email = email.trim();
|
|
const person = people.get_by_email(email);
|
|
if (person !== undefined) {
|
|
return person.full_name;
|
|
}
|
|
return email;
|
|
}).join(', ');
|
|
|
|
formatted = {
|
|
draft_id: draft.id,
|
|
is_stream: false,
|
|
recipients: recipients,
|
|
raw_content: draft.content,
|
|
time_stamp: time_stamp,
|
|
};
|
|
}
|
|
|
|
try {
|
|
markdown.apply_markdown(formatted);
|
|
} catch (error) {
|
|
// In the unlikely event that there is syntax in the
|
|
// draft content which our markdown processor is
|
|
// unable to process, we delete the draft, so that the
|
|
// drafts overlay can be opened without any errors.
|
|
// We also report the exception to the server so that
|
|
// the bug can be fixed.
|
|
draft_model.deleteDraft(id);
|
|
blueslip.error("Error in rendering draft.", {
|
|
draft_content: draft.content,
|
|
}, error.stack);
|
|
return;
|
|
}
|
|
|
|
return formatted;
|
|
};
|
|
|
|
function row_with_focus() {
|
|
const focused_draft = $(".draft-info-box:focus")[0];
|
|
return $(focused_draft).parent(".draft-row");
|
|
}
|
|
|
|
function row_before_focus() {
|
|
const focused_row = row_with_focus();
|
|
return focused_row.prev(".draft-row:visible");
|
|
}
|
|
|
|
function row_after_focus() {
|
|
const focused_row = row_with_focus();
|
|
return focused_row.next(".draft-row:visible");
|
|
}
|
|
|
|
function remove_draft(draft_row) {
|
|
// Deletes the draft and removes it from the list
|
|
const draft_id = draft_row.data("draft-id");
|
|
|
|
exports.draft_model.deleteDraft(draft_id);
|
|
|
|
draft_row.remove();
|
|
|
|
if ($("#drafts_table .draft-row").length === 0) {
|
|
$('#drafts_table .no-drafts').show();
|
|
}
|
|
}
|
|
|
|
exports.launch = function () {
|
|
function format_drafts(data) {
|
|
for (const [id, draft] of Object.entries(data)) {
|
|
draft.id = id;
|
|
}
|
|
|
|
const unsorted_raw_drafts = Object.values(data);
|
|
|
|
const sorted_raw_drafts = unsorted_raw_drafts.sort(function (draft_a, draft_b) {
|
|
return draft_b.updatedAt - draft_a.updatedAt;
|
|
});
|
|
|
|
const sorted_formatted_drafts = sorted_raw_drafts.map(exports.format_draft).filter(Boolean);
|
|
|
|
return sorted_formatted_drafts;
|
|
}
|
|
|
|
function render_widgets(drafts) {
|
|
$('#drafts_table').empty();
|
|
const rendered = render_draft_table_body({
|
|
drafts: drafts,
|
|
draft_lifetime: DRAFT_LIFETIME,
|
|
});
|
|
$('#drafts_table').append(rendered);
|
|
if ($("#drafts_table .draft-row").length > 0) {
|
|
$('#drafts_table .no-drafts').hide();
|
|
}
|
|
}
|
|
|
|
function setup_event_handlers() {
|
|
$(".restore-draft").on("click", function (e) {
|
|
e.stopPropagation();
|
|
|
|
const draft_row = $(this).closest(".draft-row");
|
|
const draft_id = draft_row.data("draft-id");
|
|
exports.restore_draft(draft_id);
|
|
});
|
|
|
|
$(".draft_controls .delete-draft").on("click", function () {
|
|
const draft_row = $(this).closest(".draft-row");
|
|
|
|
remove_draft(draft_row);
|
|
});
|
|
}
|
|
|
|
exports.remove_old_drafts();
|
|
const drafts = format_drafts(draft_model.get());
|
|
render_widgets(drafts);
|
|
|
|
// We need to force a style calculation on the newly created
|
|
// element in order for the CSS transition to take effect.
|
|
$('#draft_overlay').css('opacity');
|
|
|
|
exports.open_modal();
|
|
exports.set_initial_element(drafts);
|
|
setup_event_handlers();
|
|
};
|
|
|
|
function activate_element(elem) {
|
|
$('.draft-info-box').removeClass('active');
|
|
$(elem).expectOne().addClass('active');
|
|
elem.focus();
|
|
}
|
|
|
|
function drafts_initialize_focus(event_name) {
|
|
// If a draft is not focused in draft modal, then focus the last draft
|
|
// if up_arrow is clicked or the first draft if down_arrow is clicked.
|
|
if (event_name !== "up_arrow" && event_name !== "down_arrow" || $(".draft-info-box:focus")[0]) {
|
|
return;
|
|
}
|
|
|
|
const draft_arrow = draft_model.get();
|
|
const draft_id_arrow = Object.getOwnPropertyNames(draft_arrow);
|
|
if (draft_id_arrow.length === 0) { // empty drafts modal
|
|
return;
|
|
}
|
|
|
|
let draft_element;
|
|
if (event_name === "up_arrow") {
|
|
draft_element = document.querySelectorAll('[data-draft-id="' + draft_id_arrow[draft_id_arrow.length - 1] + '"]');
|
|
} else if (event_name === "down_arrow") {
|
|
draft_element = document.querySelectorAll('[data-draft-id="' + draft_id_arrow[0] + '"]');
|
|
}
|
|
const focus_element = draft_element[0].children[0];
|
|
|
|
activate_element(focus_element);
|
|
}
|
|
|
|
function drafts_scroll(next_focus_draft_row) {
|
|
if (next_focus_draft_row[0] === undefined) {
|
|
return;
|
|
}
|
|
if (next_focus_draft_row[0].children[0] === undefined) {
|
|
return;
|
|
}
|
|
activate_element(next_focus_draft_row[0].children[0]);
|
|
|
|
// If focused draft is first draft, scroll to the top.
|
|
if ($(".draft-info-box").first()[0].parentElement === next_focus_draft_row[0]) {
|
|
$(".drafts-list")[0].scrollTop = 0;
|
|
}
|
|
|
|
// If focused draft is the last draft, scroll to the bottom.
|
|
if ($(".draft-info-box").last()[0].parentElement === next_focus_draft_row[0]) {
|
|
$(".drafts-list")[0].scrollTop = $('.drafts-list')[0].scrollHeight - $('.drafts-list').height();
|
|
}
|
|
|
|
// If focused draft is cut off from the top, scroll up halfway in draft modal.
|
|
if (next_focus_draft_row.position().top < 55) {
|
|
// 55 is the minimum distance from the top that will require extra scrolling.
|
|
$(".drafts-list")[0].scrollTop -= $(".drafts-list")[0].clientHeight / 2;
|
|
}
|
|
|
|
// If focused draft is cut off from the bottom, scroll down halfway in draft modal.
|
|
const dist_from_top = next_focus_draft_row.position().top;
|
|
const total_dist = dist_from_top + next_focus_draft_row[0].clientHeight;
|
|
const dist_from_bottom = $(".drafts-container")[0].clientHeight - total_dist;
|
|
if (dist_from_bottom < -4) {
|
|
//-4 is the min dist from the bottom that will require extra scrolling.
|
|
$(".drafts-list")[0].scrollTop += $(".drafts-list")[0].clientHeight / 2;
|
|
}
|
|
}
|
|
|
|
exports.drafts_handle_events = function (e, event_key) {
|
|
const draft_arrow = draft_model.get();
|
|
const draft_id_arrow = Object.getOwnPropertyNames(draft_arrow);
|
|
drafts_initialize_focus(event_key);
|
|
|
|
// This detects up arrow key presses when the draft overlay
|
|
// is open and scrolls through the drafts.
|
|
if (event_key === "up_arrow") {
|
|
drafts_scroll(row_before_focus());
|
|
}
|
|
|
|
// This detects down arrow key presses when the draft overlay
|
|
// is open and scrolls through the drafts.
|
|
if (event_key === "down_arrow") {
|
|
drafts_scroll(row_after_focus());
|
|
}
|
|
|
|
const focused_draft_id = row_with_focus().data("draft-id");
|
|
// Allows user to delete drafts with backspace
|
|
if (event_key === "backspace" || event_key === "delete") {
|
|
if (focused_draft_id !== undefined) {
|
|
const draft_row = row_with_focus();
|
|
const next_draft_row = row_after_focus();
|
|
const prev_draft_row = row_before_focus();
|
|
let draft_to_be_focused_id;
|
|
|
|
// Try to get the next draft in the list and 'focus' it
|
|
// Use previous draft as a fallback
|
|
if (next_draft_row[0] !== undefined) {
|
|
draft_to_be_focused_id = next_draft_row.data("draft-id");
|
|
} else if (prev_draft_row[0] !== undefined) {
|
|
draft_to_be_focused_id = prev_draft_row.data("draft-id");
|
|
}
|
|
|
|
const new_focus_element = document.querySelectorAll('[data-draft-id="' + draft_to_be_focused_id + '"]');
|
|
if (new_focus_element[0] !== undefined) {
|
|
activate_element(new_focus_element[0].children[0]);
|
|
}
|
|
|
|
remove_draft(draft_row);
|
|
}
|
|
}
|
|
|
|
// This handles when pressing enter while looking at drafts.
|
|
// It restores draft that is focused.
|
|
if (event_key === "enter") {
|
|
if (document.activeElement.parentElement.hasAttribute("data-draft-id")) {
|
|
exports.restore_draft(focused_draft_id);
|
|
} else {
|
|
const first_draft = draft_id_arrow[draft_id_arrow.length - 1];
|
|
exports.restore_draft(first_draft);
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.open_modal = function () {
|
|
overlays.open_overlay({
|
|
name: 'drafts',
|
|
overlay: $('#draft_overlay'),
|
|
on_close: function () {
|
|
hashchange.exit_overlay();
|
|
},
|
|
});
|
|
};
|
|
|
|
exports.set_initial_element = function (drafts) {
|
|
if (drafts.length > 0) {
|
|
const curr_draft_id = drafts[0].draft_id;
|
|
const selector = '[data-draft-id="' + curr_draft_id + '"]';
|
|
const curr_draft_element = document.querySelectorAll(selector);
|
|
const focus_element = curr_draft_element[0].children[0];
|
|
activate_element(focus_element);
|
|
$(".drafts-list")[0].scrollTop = 0;
|
|
}
|
|
};
|
|
|
|
exports.initialize = function () {
|
|
window.addEventListener("beforeunload", function () {
|
|
exports.update_draft();
|
|
});
|
|
|
|
$("#compose-textarea").focusout(exports.update_draft);
|
|
|
|
$('body').on('focus', '.draft-info-box', function (e) {
|
|
activate_element(e.target);
|
|
});
|
|
};
|
|
|
|
window.drafts = exports;
|