upload: Support drag and dropping files anywhere on message viewport.

While Zulip has supported drag/drop into the compose box for some
time, if you drag/drop the file onto other parts of the message
viewport, it would just do the default browswer behavior of replacing
the Zulip app with that file opened in a new tab, which nobody wants.

The inline comments document the set of rules for how we choose
whether to drop the upload into the compose box or an edit widget, and
also how to open the compose box in different situations.

Fixes #14579.
This commit is contained in:
Brijmohan Siyag
2023-06-16 16:19:02 +05:30
committed by Tim Abbott
parent 1b9fb037a1
commit 4e7bf3c4fb
5 changed files with 251 additions and 2 deletions

View File

@@ -56,6 +56,9 @@ import * as zcommand from "./zcommand";
let uppy;
export function get_compose_upload_object() {
return uppy;
}
export function compute_show_video_chat_button() {
const available_providers = page_params.realm_available_video_chat_providers;
if (page_params.realm_video_chat_provider === available_providers.disabled.id) {

View File

@@ -742,6 +742,10 @@ export function end_inline_topic_edit($row) {
message_lists.current.hide_edit_topic_on_recipient_row($row);
}
export function get_upload_object_from_row(row_id) {
return upload_objects_by_row.get(row_id);
}
function remove_uploads_from_row(row_id) {
const uploads_for_row = upload_objects_by_row.get(row_id);
// We need to cancel all uploads, reset their progress,

View File

@@ -113,6 +113,7 @@ import * as typing from "./typing";
import * as unread from "./unread";
import * as unread_ops from "./unread_ops";
import * as unread_ui from "./unread_ui";
import * as upload from "./upload";
import * as user_group_edit from "./user_group_edit";
import * as user_group_edit_members from "./user_group_edit_members";
import * as user_groups from "./user_groups";
@@ -762,6 +763,7 @@ export function initialize_everything() {
hotspots.initialize();
typing.initialize();
starred_messages_ui.initialize();
upload.initialize();
user_status_ui.initialize();
fenced_code.initialize(generated_pygments_data);
message_edit_history.initialize();

View File

@@ -4,14 +4,17 @@ import $ from "jquery";
import render_upload_banner from "../templates/compose_banner/upload_banner.hbs";
import * as compose from "./compose";
import * as compose_actions from "./compose_actions";
import * as compose_banner from "./compose_banner";
import * as compose_state from "./compose_state";
import * as compose_ui from "./compose_ui";
import {csrf_token} from "./csrf";
import {$t} from "./i18n";
import * as message_edit from "./message_edit";
import * as message_lists from "./message_lists";
import {page_params} from "./page_params";
import * as rows from "./rows";
// Show the upload button only if the browser supports it.
export function feature_check($upload_button) {
if (window.XMLHttpRequest && new window.XMLHttpRequest().upload) {
@@ -304,6 +307,7 @@ export function setup_upload(config) {
$drag_drop_container.on("drop", (event) => {
event.preventDefault();
event.stopPropagation();
const files = event.originalEvent.dataTransfer.files;
if (config.mode === "compose" && !compose_state.composing()) {
compose_actions.respond_to_message({trigger: "file drop or paste"});
@@ -404,3 +408,51 @@ export function setup_upload(config) {
return uppy;
}
export function initialize() {
// Allow the main panel to receive drag/drop events.
$(".app-main").on("dragover", (event) => event.preventDefault());
// TODO: Do something visual to hint that drag/drop will work.
$(".app-main").on("dragenter", (event) => event.preventDefault());
$(".app-main").on("drop", (event) => {
event.preventDefault();
const $drag_drop_edit_containers = $(".message_edit_form form");
const files = event.originalEvent.dataTransfer.files;
const compose_upload_object = compose.get_compose_upload_object();
const $last_drag_drop_edit_container = $drag_drop_edit_containers.last();
// Handlers registered on individual inputs will ensure that
// drag/dropping directly onto a compose/edit input will put
// the upload there. Here, we handle drag/drop events that
// land somewhere else in the center pane.
if (compose_state.composing()) {
// Compose box is open; drop there.
upload_files(compose_upload_object, {mode: "compose"}, files);
} else if ($last_drag_drop_edit_container.length !== 0) {
// A message edit box is open; drop there.
const row_id = rows.get_message_id($last_drag_drop_edit_container);
const $drag_drop_container = get_item("drag_drop_container", {
mode: "edit",
row: row_id,
});
if (!$drag_drop_container.closest("html").length) {
return;
}
const edit_upload_object = message_edit.get_upload_object_from_row(row_id);
upload_files(edit_upload_object, {mode: "edit", row: row_id}, files);
} else if (message_lists.current.selected_message()) {
// Start a reply to selected message, if viewing a message feed.
compose_actions.respond_to_message({trigger: "drag_drop_file"});
upload_files(compose_upload_object, {mode: "compose"}, files);
} else {
// Start a new message in other views.
compose_actions.start("stream", {trigger: "drag_drop_file"});
upload_files(compose_upload_object, {mode: "compose"}, files);
}
});
}

View File

@@ -7,6 +7,9 @@ const {run_test} = require("./lib/test");
const $ = require("./lib/zjquery");
const {page_params} = require("./lib/zpage_params");
const compose_state = zrequire("compose_state");
const rows = zrequire("rows");
set_global("navigator", {
userAgent: "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)",
});
@@ -17,6 +20,7 @@ mock_esm("@uppy/core", {
return uppy_stub.call(this, options);
},
});
mock_esm("@uppy/xhr-upload", {default: class XHRUpload {}});
const compose_actions = mock_esm("../src/compose_actions");
@@ -24,7 +28,7 @@ mock_esm("../src/csrf", {csrf_token: "csrf_token"});
const compose_ui = zrequire("compose_ui");
const upload = zrequire("upload");
const message_lists = zrequire("message_lists");
function test(label, f) {
run_test(label, (helpers) => {
page_params.max_file_upload_size_mib = 25;
@@ -420,11 +424,15 @@ test("file_drop", ({override, override_rewire}) => {
dragenter_handler(drag_event);
assert.equal(prevent_default_counter, 2);
let stop_propogation_counter = 0;
const files = ["file1", "file2"];
const drop_event = {
preventDefault() {
prevent_default_counter += 1;
},
stopPropagation() {
stop_propogation_counter += 1;
},
originalEvent: {
dataTransfer: {
files,
@@ -443,6 +451,7 @@ test("file_drop", ({override, override_rewire}) => {
drop_handler(drop_event);
assert.ok(compose_actions_start_called);
assert.equal(prevent_default_counter, 3);
assert.equal(stop_propogation_counter, 1);
assert.equal(upload_files_called, true);
});
@@ -621,3 +630,182 @@ test("uppy_events", ({override_rewire, mock_template}) => {
assert.ok(compose_ui_replace_syntax_called);
assert.equal($("#compose-textarea").val(), "user modified text");
});
test("main_file_drop_compose_mode", ({override_rewire}) => {
uppy_stub = function () {
return {
setMeta() {},
use() {},
cancelAll() {},
on() {},
getFiles() {},
removeFile() {},
};
};
upload.setup_upload({mode: "compose"});
upload.initialize();
let prevent_default_counter = 0;
const drag_event = {
preventDefault() {
prevent_default_counter += 1;
},
};
// dragover event test
const dragover_handler = $(".app-main").get_on_handler("dragover");
dragover_handler(drag_event);
assert.equal(prevent_default_counter, 1);
// dragenter event test
const dragenter_handler = $(".app-main").get_on_handler("dragenter");
dragenter_handler(drag_event);
assert.equal(prevent_default_counter, 2);
const files = ["file1", "file2"];
const drop_event = {
target: "target",
preventDefault() {
prevent_default_counter += 1;
},
originalEvent: {
dataTransfer: {
files,
},
},
};
$(".message_edit_form form").last = () => ({length: 0});
const drop_handler = $(".app-main").get_on_handler("drop");
// Test drop on compose box
let upload_files_called = false;
override_rewire(upload, "upload_files", () => {
upload_files_called = true;
});
compose_state.composing = () => true;
drop_handler(drop_event);
assert.equal(upload_files_called, true);
assert.equal(prevent_default_counter, 3);
// Test reply to message if no edit and compose box open
upload_files_called = false;
compose_state.composing = () => false;
const msg = {
type: "stream",
stream: "Denmark",
topic: "python",
sender_full_name: "Bob Roberts",
sender_id: 40,
};
let compose_actions_start_called = false;
let compose_actions_respond_to_message_called = false;
override_rewire(message_lists, "current", {
selected_message() {
return msg;
},
});
compose_actions.start = () => {
compose_actions_start_called = true;
};
compose_actions.respond_to_message = () => {
compose_actions_respond_to_message_called = true;
};
drop_handler(drop_event);
assert.equal(upload_files_called, true);
assert.equal(compose_actions_start_called, false);
assert.equal(compose_actions_respond_to_message_called, true);
// Test drop on recent topics view
compose_actions_respond_to_message_called = false;
override_rewire(message_lists, "current", {
selected_message() {
return undefined;
},
});
upload_files_called = false;
drop_handler(drop_event);
assert.equal(upload_files_called, true);
assert.equal(compose_actions_start_called, true);
assert.equal(compose_actions_respond_to_message_called, false);
});
test("main_file_drop_edit_mode", ({override_rewire}) => {
uppy_stub = function () {
return {
setMeta() {},
use() {},
cancelAll() {},
on() {},
getFiles() {},
removeFile() {},
};
};
upload.setup_upload({mode: "edit", row: 40});
upload.initialize();
compose_state.composing = () => false;
let prevent_default_counter = 0;
const drag_event = {
preventDefault() {
prevent_default_counter += 1;
},
};
const $drag_drop_container = $(`#zfilt${CSS.escape(40)} .message_edit_form`);
// Dragover event test
const dragover_handler = $(".app-main").get_on_handler("dragover");
dragover_handler(drag_event);
assert.equal(prevent_default_counter, 1);
// Dragenter event test
const dragenter_handler = $(".app-main").get_on_handler("dragenter");
dragenter_handler(drag_event);
assert.equal(prevent_default_counter, 2);
const files = ["file1", "file2"];
const drop_event = {
target: "target",
preventDefault() {
prevent_default_counter += 1;
},
originalEvent: {
dataTransfer: {
files,
},
},
};
const drop_handler = $(".app-main").get_on_handler("drop");
let upload_files_called = false;
let dropped_row_id = -1;
override_rewire(upload, "upload_files", (_, config) => {
dropped_row_id = config.row;
upload_files_called = true;
});
$(".message_edit_form form").last = () => ({length: 1});
rows.get_message_id = () => 40;
// Edit box which registered the event handler no longer exists.
$drag_drop_container.closest = (element) => {
assert.equal(element, "html");
return {length: 0};
};
drop_handler(drop_event);
assert.equal(upload_files_called, false);
$drag_drop_container.closest = (element) => {
assert.equal(element, "html");
return {length: 1};
};
// Drag and dropped in one of the edit boxes. The event would be taken care of by
// drag_drop_container event handlers.
rows.get_message_id = () => 40;
// Edit box open
$(".message_edit_form form").last = () => ({length: 1});
drop_handler(drop_event);
assert.equal(upload_files_called, true);
assert.equal(dropped_row_id, 40);
});