mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a99198655e |
No files matched your search
@@ -24,10 +24,10 @@ import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import UIAuthLogin from '../Auth/UIAuthLogin.js';
|
||||
import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
import apply_item_added_to_containers from '../../helpers/applyItemAddedToContainers.js';
|
||||
import { clear_shared_param } from '../../helpers/parseSharedPath.js';
|
||||
import UIDashboardNotifications from './UIDashboardNotifications.js';
|
||||
import UIDashboardContactDialog from './UIDashboardContactDialog.js';
|
||||
import {
|
||||
answerNotificationReceipts,
|
||||
notificationSocketAcks,
|
||||
@@ -900,16 +900,8 @@ async function UIDashboard (options) {
|
||||
// Contact Us
|
||||
{
|
||||
html: i18n('contact_us'),
|
||||
onClick: async function () {
|
||||
UIWindowFeedback({
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
onClick: function () {
|
||||
UIDashboardContactDialog({ $container: $el_window, returnFocusTo: $btn.get(0) });
|
||||
},
|
||||
},
|
||||
'-',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The dashboard's Contact Us form: a dashboard form dialog with screenshots
|
||||
// and screen recordings attached by picking, dropping or pasting them. The
|
||||
// desktop keeps UIWindowFeedback.
|
||||
|
||||
import {
|
||||
ATTACHMENT_ACCEPT_ATTRIBUTE,
|
||||
MAX_ATTACHMENTS,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_TOTAL_ATTACHMENT_BYTES,
|
||||
checkAttachment,
|
||||
} from '../../helpers/contact_attachments.js';
|
||||
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import { ICONS, SVG } from './UIDashboardDialog.js';
|
||||
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
// Keep in sync with SystemController on the backend.
|
||||
const MESSAGE_MAX_LENGTH = 100_000;
|
||||
|
||||
// Numbers interpolated into each error message, taken from the enforced limits.
|
||||
const ERROR_VALUES = {
|
||||
contact_us_attachment_too_many: [MAX_ATTACHMENTS],
|
||||
contact_us_attachment_too_large: [MAX_ATTACHMENT_BYTES / MB],
|
||||
contact_us_attachment_total_too_large: [MAX_TOTAL_ATTACHMENT_BYTES / MB],
|
||||
};
|
||||
|
||||
const CONTACT_ICONS = {
|
||||
attach: SVG('<path d="M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/>'),
|
||||
video: SVG('<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 10l6-3.5v11L16 14"/>'),
|
||||
};
|
||||
|
||||
const t = (key, values = []) => i18n(key, values, false);
|
||||
|
||||
/**
|
||||
* Whether a drag carries files, rather than text or a link from the page.
|
||||
*
|
||||
* @param {DragEvent} e
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const dragHasFiles = (e) => Array.from(e.originalEvent?.dataTransfer?.types ?? []).includes('Files');
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {string}
|
||||
*/
|
||||
const attachmentHtml = (file) => {
|
||||
const name = html_encode(file.name);
|
||||
// The preview's URL is let go once it has drawn; the <img> keeps the pixels.
|
||||
const thumb = file.type.startsWith('image/')
|
||||
? `<img src="${html_encode(URL.createObjectURL(file))}" alt="" draggable="false">`
|
||||
: CONTACT_ICONS.video;
|
||||
const remove = html_encode(t('contact_us_attachment_remove_named', [file.name]));
|
||||
return '<li class="dashboard-contact-file">'
|
||||
+ `<span class="dashboard-contact-file-thumb" aria-hidden="true">${thumb}</span>`
|
||||
+ '<span class="dashboard-contact-file-text">'
|
||||
+ `<span class="dashboard-contact-file-name" title="${name}">${name}</span>`
|
||||
+ `<span class="dashboard-contact-file-size">${html_encode(window.byte_format(file.size))}</span>`
|
||||
+ '</span>'
|
||||
+ `<button type="button" class="dashboard-contact-file-remove" aria-label="${remove}" title="${i18n('contact_us_attachment_remove')}">${ICONS.close}</button>`
|
||||
+ '</li>';
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the Contact Us form; the message goes to support, with any
|
||||
* attachments as multipart form data. The checks here only fail fast: the
|
||||
* server enforces the limits.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
|
||||
* @returns {import('./UIDashboardFormDialog.js').FormDialog}
|
||||
*/
|
||||
export default function UIDashboardContactDialog ({ $container, returnFocusTo } = {}) {
|
||||
/** @type {File[]} */
|
||||
const attachments = [];
|
||||
let sending = false;
|
||||
// Once sent, the form is gone and a drop or paste has nothing to join.
|
||||
let sent = false;
|
||||
|
||||
const dialog = UIDashboardFormDialog({
|
||||
$container,
|
||||
returnFocusTo,
|
||||
title: t('contact_us'),
|
||||
intro: `<p>${i18n('feedback_c2a')}</p>`,
|
||||
submitLabel: t('send'),
|
||||
fields: [{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
label: i18n('contact_us_message'),
|
||||
placeholder: i18n('contact_us_message_placeholder'),
|
||||
attrs: { rows: 6, maxlength: MESSAGE_MAX_LENGTH },
|
||||
}],
|
||||
async onSubmit () {
|
||||
const { message } = dialog.values();
|
||||
if ( ! message.trim() ) {
|
||||
dialog.setStatus(t('contact_us_message_required'), { field: 'message' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('message', message);
|
||||
for ( const file of attachments ) body.append('attachments', file, file.name);
|
||||
|
||||
// No Content-Type: the browser sets it with the boundary.
|
||||
const resp = await fetch(`${window.api_origin}/contactUs`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${window.auth_token}` },
|
||||
body,
|
||||
});
|
||||
if ( ! resp.ok ) {
|
||||
dialog.setStatus(t(resp.status === 429 ? 'contact_us_rate_limited' : 'contact_us_error'));
|
||||
return;
|
||||
}
|
||||
sent = true;
|
||||
dialog.finish({ message: t('feedback_sent_confirmation') });
|
||||
} catch ( err ) {
|
||||
console.error('contact-us: submit failed', err);
|
||||
dialog.setStatus(t('contact_us_error'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { $dialog } = dialog;
|
||||
const $overlay = $dialog.parent();
|
||||
const hint_id = `${$dialog.attr('aria-labelledby')}-attach-hint`;
|
||||
|
||||
let h = '<div class="dashboard-contact-attachments">';
|
||||
h += `<input type="file" class="dashboard-contact-file-input" accept="${html_encode(ATTACHMENT_ACCEPT_ATTRIBUTE)}" multiple hidden tabindex="-1">`;
|
||||
h += `<button type="button" class="dashboard-contact-dropzone" aria-describedby="${hint_id}">`;
|
||||
h += `<span class="dashboard-contact-dropzone-icon" aria-hidden="true">${CONTACT_ICONS.attach}</span>`;
|
||||
h += '<span class="dashboard-contact-dropzone-text">';
|
||||
h += `<span class="dashboard-contact-dropzone-label">${i18n(isTouchPrimaryDevice() ? 'contact_us_attach' : 'contact_us_attach_or_drop')}</span>`;
|
||||
h += `<span class="dashboard-contact-dropzone-drop" aria-hidden="true">${i18n('contact_us_drop_to_attach')}</span>`;
|
||||
h += `<span class="dashboard-contact-dropzone-hint" id="${hint_id}">${i18n('contact_us_attach_hint', [MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES / MB])}</span>`;
|
||||
h += '</span>';
|
||||
h += '</button>';
|
||||
h += `<ul class="dashboard-contact-files" aria-label="${i18n('contact_us_attachments')}" hidden></ul>`;
|
||||
h += '</div>';
|
||||
$dialog.addClass('dashboard-contact-dialog')
|
||||
.find('.dashboard-form-field[data-field="message"]').after(h);
|
||||
|
||||
const $dropzone = $dialog.find('.dashboard-contact-dropzone');
|
||||
const $fileInput = $dialog.find('.dashboard-contact-file-input');
|
||||
const $list = $dialog.find('.dashboard-contact-files');
|
||||
|
||||
const syncControls = () => {
|
||||
$list.prop('hidden', attachments.length === 0);
|
||||
// Nothing left to add once the count cap is reached.
|
||||
$dropzone.prop('disabled', sending || attachments.length >= MAX_ATTACHMENTS);
|
||||
$list.find('.dashboard-contact-file-remove').prop('disabled', sending);
|
||||
};
|
||||
|
||||
function setSending (value) {
|
||||
sending = value;
|
||||
$dialog.toggleClass('dashboard-contact-sending', value);
|
||||
syncControls();
|
||||
}
|
||||
|
||||
/** @param {File[]} files */
|
||||
const addFiles = (files) => {
|
||||
if ( sending || sent || ! files.length ) return;
|
||||
let rejection = null;
|
||||
for ( const file of files ) {
|
||||
const verdict = checkAttachment(file, attachments);
|
||||
if ( ! verdict.ok ) {
|
||||
// Report the first rejection but keep the files that fit.
|
||||
rejection = rejection ?? verdict.error;
|
||||
continue;
|
||||
}
|
||||
attachments.push(file);
|
||||
const $item = $(attachmentHtml(file));
|
||||
$item.find('img').one('load error', function () {
|
||||
URL.revokeObjectURL(this.src);
|
||||
});
|
||||
$list.append($item);
|
||||
}
|
||||
dialog.setStatus(rejection ? t(rejection, ERROR_VALUES[rejection] ?? []) : '');
|
||||
syncControls();
|
||||
};
|
||||
|
||||
$dropzone.on('click', () => $fileInput.trigger('click'));
|
||||
$fileInput.on('change', function () {
|
||||
addFiles(Array.from(this.files ?? []));
|
||||
// Reset so re-picking the same file fires `change` again.
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
$list.on('click', '.dashboard-contact-file-remove', function () {
|
||||
if ( sending ) return;
|
||||
const $item = $(this).closest('.dashboard-contact-file');
|
||||
const index = $list.children().index($item);
|
||||
attachments.splice(index, 1);
|
||||
// Focus stays in the list rather than falling back to the page.
|
||||
const $next = $item.next().length ? $item.next() : $item.prev();
|
||||
$item.remove();
|
||||
dialog.setStatus('');
|
||||
syncControls();
|
||||
($next.length ? $next.find('.dashboard-contact-file-remove') : $dropzone).get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// The whole overlay takes the drop, and keeps it from the window under it.
|
||||
let drag_depth = 0;
|
||||
const endDrag = () => {
|
||||
drag_depth = 0;
|
||||
$dialog.removeClass('dashboard-contact-dragging');
|
||||
};
|
||||
$overlay.on('dragenter', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drag_depth++;
|
||||
if ( ! sending && ! sent ) $dialog.addClass('dashboard-contact-dragging');
|
||||
});
|
||||
$overlay.on('dragover', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.originalEvent.dataTransfer.dropEffect = sending || sent ? 'none' : 'copy';
|
||||
});
|
||||
$overlay.on('dragleave', (e) => {
|
||||
// Matched to the dragenters counted, whatever this one says it carries.
|
||||
if ( drag_depth === 0 ) return;
|
||||
e.stopPropagation();
|
||||
if ( --drag_depth <= 0 ) endDrag();
|
||||
});
|
||||
$overlay.on('drop', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
endDrag();
|
||||
addFiles(Array.from(e.originalEvent.dataTransfer.files ?? []));
|
||||
});
|
||||
|
||||
// A screenshot pasted into the message is attached to it.
|
||||
$dialog.on('paste', '.dashboard-form-input[name="message"]', (e) => {
|
||||
const clipboard = e.originalEvent?.clipboardData;
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if ( ! files.length ) return;
|
||||
// Some apps put the image's name or markup alongside it; that text still pastes.
|
||||
if ( ! clipboard.getData('text/plain') ) e.preventDefault();
|
||||
addFiles(files);
|
||||
});
|
||||
|
||||
// Enter alone is a new line in the message; with Cmd or Ctrl, it sends.
|
||||
$dialog.on('keydown', '.dashboard-form-input[name="message"]', (e) => {
|
||||
if ( e.key !== 'Enter' || ! (e.metaKey || e.ctrlKey) ) return;
|
||||
e.preventDefault();
|
||||
$dialog.trigger('submit');
|
||||
});
|
||||
|
||||
syncControls();
|
||||
return dialog;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
import { MAX_ATTACHMENTS } from '../../helpers/contact_attachments.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, values = []) => [key, ...values].join(' ');
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
window.byte_format = (bytes) => `${bytes} B`;
|
||||
URL.createObjectURL = vi.fn(() => 'blob:preview');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
const { default: UIDashboardContactDialog } = await import('./UIDashboardContactDialog.js');
|
||||
|
||||
const settle = async () => {
|
||||
for ( let i = 0; i < 10; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
const message = () => $('.dashboard-form-input[name="message"]');
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
const fileNames = () => $('.dashboard-contact-file-name').map((_, el) => el.textContent).get();
|
||||
const png = (name = 'shot.png', size = 1024) => new File([new Uint8Array(size)], name, { type: 'image/png' });
|
||||
const mp4 = (name = 'clip.mp4') => new File([new Uint8Array(2048)], name, { type: 'video/mp4' });
|
||||
|
||||
/** Picks files through the hidden file input, as the system picker would. */
|
||||
const pick = (...files) => {
|
||||
const input = $('.dashboard-contact-file-input').get(0);
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||||
$(input).trigger('change');
|
||||
};
|
||||
|
||||
/** A native-event stand-in for jQuery to wrap, carrying `fields`. */
|
||||
const nativeEvent = (fields) => ({ preventDefault () {}, stopPropagation () {}, ...fields });
|
||||
|
||||
/** A file drag event carrying `files`, as the browser sends it. */
|
||||
const fileDrag = (type, files = []) => $.Event(type, {
|
||||
originalEvent: nativeEvent({ dataTransfer: { types: ['Files'], files, dropEffect: 'none' } }),
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
await settle();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.fetch = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'tok-1';
|
||||
});
|
||||
|
||||
describe('Contact Us dialog', () => {
|
||||
it('opens as a dashboard form dialog with no attachments listed', () => {
|
||||
UIDashboardContactDialog();
|
||||
expect($('.dashboard-dialog-overlay .dashboard-contact-dialog').length).toBe(1);
|
||||
expect(message().length).toBe(1);
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('asks for a message before sending', async () => {
|
||||
UIDashboardContactDialog();
|
||||
message().val(' ');
|
||||
await submit();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(status()).toBe('contact_us_message_required');
|
||||
expect(message().attr('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('posts the message and attachments as multipart and confirms', async () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png(), mp4());
|
||||
message().val('It broke');
|
||||
await submit();
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.puter.test/contactUs');
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer tok-1' });
|
||||
expect(init.body.get('message')).toBe('It broke');
|
||||
expect(init.body.getAll('attachments').map(f => f.name)).toEqual(['shot.png', 'clip.mp4']);
|
||||
expect($('.dashboard-form-done').text()).toContain('feedback_sent_confirmation');
|
||||
expect($('.dashboard-contact-attachments').length).toBe(0);
|
||||
});
|
||||
|
||||
it('says when the sender is rate limited and keeps the form', async () => {
|
||||
fetch.mockResolvedValueOnce(new Response('{}', { status: 429 }));
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(status()).toBe('contact_us_rate_limited');
|
||||
expect(message().val()).toBe('Hello');
|
||||
expect(message().prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a generic error when the request fails', async () => {
|
||||
fetch.mockRejectedValueOnce(new TypeError('offline'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(status()).toBe('contact_us_error');
|
||||
});
|
||||
|
||||
it('locks the attachments while sending', async () => {
|
||||
let respond;
|
||||
fetch.mockReturnValueOnce(new Promise(resolve => {
|
||||
respond = resolve;
|
||||
}));
|
||||
UIDashboardContactDialog();
|
||||
pick(png());
|
||||
message().val('Hello');
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
await settle();
|
||||
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
|
||||
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(true);
|
||||
pick(png('late.png'));
|
||||
expect(fileNames()).toEqual(['shot.png']);
|
||||
|
||||
respond(new Response('{}', { status: 500 }));
|
||||
await settle();
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(false);
|
||||
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
it('lists picked files with a preview for images', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png('a.png'), mp4('b.mp4'));
|
||||
expect(fileNames()).toEqual(['a.png', 'b.mp4']);
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(false);
|
||||
expect($('.dashboard-contact-file').eq(0).find('img').attr('src')).toBe('blob:preview');
|
||||
expect($('.dashboard-contact-file').eq(1).find('img').length).toBe(0);
|
||||
expect($('.dashboard-contact-file-remove').eq(0).attr('aria-label')).toBe('contact_us_attachment_remove_named a.png');
|
||||
});
|
||||
|
||||
it('keeps the files that fit and reports the first rejection', () => {
|
||||
UIDashboardContactDialog();
|
||||
const text = new File(['x'], 'notes.txt', { type: 'text/plain' });
|
||||
pick(png('a.png'), text, png('b.png'));
|
||||
expect(fileNames()).toEqual(['a.png', 'b.png']);
|
||||
expect(status()).toBe('contact_us_attachment_unsupported');
|
||||
});
|
||||
|
||||
it('stops offering to attach once the count cap is reached', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(...Array.from({ length: MAX_ATTACHMENTS }, (_, i) => png(`${i}.png`)));
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
|
||||
pick(png('extra.png'));
|
||||
expect(fileNames()).toHaveLength(MAX_ATTACHMENTS);
|
||||
expect(status()).toBe(`contact_us_attachment_too_many ${MAX_ATTACHMENTS}`);
|
||||
});
|
||||
|
||||
it('removes the file whose button is pressed and moves focus to the next one', async () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png('a.png'), png('b.png'), png('c.png'));
|
||||
$('.dashboard-contact-file-remove').eq(1).trigger('click');
|
||||
expect(fileNames()).toEqual(['a.png', 'c.png']);
|
||||
expect(document.activeElement).toBe($('.dashboard-contact-file-remove').get(1));
|
||||
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(fetch.mock.calls[0][1].body.getAll('attachments').map(f => f.name)).toEqual(['a.png', 'c.png']);
|
||||
});
|
||||
|
||||
it('returns focus to the attach button when the last file is removed', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png());
|
||||
$('.dashboard-contact-file-remove').trigger('click');
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
|
||||
expect(document.activeElement).toBe($('.dashboard-contact-dropzone').get(0));
|
||||
});
|
||||
|
||||
it('takes files dropped anywhere on the overlay, without letting the drop through', () => {
|
||||
const onWindowDrop = vi.fn();
|
||||
const $win = $('<div class="window"></div>').appendTo('body').on('drop', onWindowDrop);
|
||||
UIDashboardContactDialog({ $container: $win });
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
|
||||
$overlay.trigger(fileDrag('dragenter'));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
|
||||
const drop = fileDrag('drop', [png('dropped.png')]);
|
||||
$overlay.trigger(drop);
|
||||
|
||||
expect(drop.isDefaultPrevented()).toBe(true);
|
||||
expect(onWindowDrop).not.toHaveBeenCalled();
|
||||
expect(fileNames()).toEqual(['dropped.png']);
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the drop highlight when the drag leaves', () => {
|
||||
UIDashboardContactDialog();
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
$overlay.trigger(fileDrag('dragenter'));
|
||||
$overlay.find('.dashboard-contact-dropzone').trigger(fileDrag('dragenter'));
|
||||
$overlay.find('.dashboard-contact-dropzone').trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
|
||||
$overlay.trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores drags that carry no files', () => {
|
||||
UIDashboardContactDialog();
|
||||
const drag = $.Event('dragenter', { originalEvent: nativeEvent({ dataTransfer: { types: ['text/plain'] } }) });
|
||||
$('.dashboard-dialog-overlay').trigger(drag);
|
||||
expect(drag.isDefaultPrevented()).toBe(false);
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('attaches an image pasted into the message', () => {
|
||||
UIDashboardContactDialog();
|
||||
const paste = $.Event('paste', {
|
||||
originalEvent: nativeEvent({ clipboardData: { files: [png('pasted.png')], getData: () => '' } }),
|
||||
});
|
||||
message().trigger(paste);
|
||||
expect(fileNames()).toEqual(['pasted.png']);
|
||||
expect(paste.isDefaultPrevented()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard', () => {
|
||||
it('sends on Cmd/Ctrl+Enter but not on Enter alone', async () => {
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
message().trigger($.Event('keydown', { key: 'Enter' }));
|
||||
await settle();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
message().trigger($.Event('keydown', { key: 'Enter', ctrlKey: true }));
|
||||
await settle();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -10590,6 +10590,253 @@ body.dashboard-mode .notifications-close-all {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Contact Us: a message with screenshots or recordings attached */
|
||||
.dashboard-dialog.dashboard-contact-dialog {
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
.dashboard-contact-dialog .dashboard-form-input[name='message'] {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.dashboard-contact-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-primary);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-label,
|
||||
.dashboard-contact-dropzone-drop {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-drop {
|
||||
display: none;
|
||||
color: var(--select-text);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-contact-dropzone:hover:not(:disabled) {
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint-weak);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--select-color);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Files dragged anywhere over the dialog land here. */
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone {
|
||||
border-style: solid;
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone-drop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-contact-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-files[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-input-background);
|
||||
animation: dashboard-contact-file-in 160ms ease;
|
||||
}
|
||||
|
||||
@keyframes dashboard-contact-file-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
background: var(--dashboard-sidebar-background);
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-text {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dashboard-text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-size {
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-contact-file-remove:hover:not(:disabled) {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Sending: the files are on their way and no longer up for change. */
|
||||
.dashboard-contact-sending .dashboard-contact-file {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-contact-dropzone {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-contact-file {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* API token, shown once */
|
||||
.dashboard-token-warning {
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -129,13 +129,19 @@ const en = {
|
||||
choose_publishing_option: 'Choose how you want to publish your website:',
|
||||
contact_us: 'Contact Us',
|
||||
contact_us_attach: 'Attach Files',
|
||||
contact_us_attach_or_drop: 'Attach files or drop them here',
|
||||
contact_us_attach_hint: 'Images and videos — up to %% files, %% MB each.',
|
||||
contact_us_attachment_remove: 'Remove attachment',
|
||||
contact_us_attachment_remove_named: 'Remove %%',
|
||||
contact_us_attachment_too_large: 'That file is too large. Each attachment can be up to %% MB.',
|
||||
contact_us_attachment_too_many: 'You can attach up to %% files.',
|
||||
contact_us_attachment_total_too_large: 'Those files add up to more than %% MB. Try attaching fewer of them, or smaller ones.',
|
||||
contact_us_attachments: 'Attachments',
|
||||
contact_us_attachment_unsupported: 'That file type is not supported. Attach an image (PNG, JPEG, GIF, WebP) or a video (MP4, MOV, WebM).',
|
||||
contact_us_drop_to_attach: 'Drop to attach',
|
||||
contact_us_error: 'Something went wrong. Please try again.',
|
||||
contact_us_message: 'Message',
|
||||
contact_us_message_placeholder: 'How can we help? For a bug, the steps that lead to it help us most.',
|
||||
contact_us_message_required: 'Please write a message before sending.',
|
||||
contact_us_rate_limited: 'You have sent a lot of messages recently. Please try again later.',
|
||||
contact_us_verification_required: 'You must have a verified email address to use this.',
|
||||
|
||||
Reference in new issue
Block a user