mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 07:27:04 +00:00
fix: feedback modal polish + share sender email
Address four issues with the app feedback UI: - Dashboard app-drawer: the extra "feedback" control pushed the close button past the drawer's derived width and clipped it. A `has-feedback` modifier widens the surface by one button + gap so all three controls fit. The control's glyph is now a message bubble with text lines, which reads more clearly at 14px than the previous bare speech bubble. - The feedback dialog is no longer a UIWindow. It's a from-scratch overlay modal in the spirit of the dashboard modals (uninstall, add-app): a fixed scrim + centered card with self-contained, theme-aware color tokens (light default + dark override), a bottom-sheet layout on narrow screens, backdrop/Escape close, and an entrance transition. This renders consistently across the three contexts it's opened from (desktop app-IPC, dashboard drawer, standalone popup), so the callers no longer pass UIWindow-specific window_options. - Feedback now shares the sender's email (not just their username) with the developer so they can respond: the owner email sets Reply-To to the sender and shows the address in the body — but only when the sender's email is verified (an unverified address could be anyone's, so it's never used as a reply target). EmailClient.send gains an optional replyTo. The dialog note now says the email will be shared. Tests: e2e updated for the new modal (7 pass); backend feedback suite covers the verified/unverified sender-email split (sqlite + postgres); EmailClient + GUI unit suites pass; type-check clean.
This commit is contained in:
@@ -152,11 +152,16 @@ export class EmailClient extends PuterClient {
|
||||
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/** Render a template and send it to `to`. */
|
||||
/**
|
||||
* Render a template and send it to `to`. `options.replyTo` sets the
|
||||
* Reply-To header (e.g. so a recipient can respond to the originator of the
|
||||
* message rather than the no-reply From address).
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
): Promise<void> {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
@@ -168,6 +173,7 @@ export class EmailClient extends PuterClient {
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
html: compiled.html(values),
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -83,9 +83,10 @@ The Puter Team
|
||||
html: `
|
||||
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
|
||||
<p>
|
||||
<strong>{{sender_username}}</strong> sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
|
||||
<strong>{{sender_username}}</strong>{{#if sender_email}} (<a href="mailto:{{sender_email}}">{{sender_email}}</a>){{/if}} sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
|
||||
</p>
|
||||
<blockquote>{{{nl2br message}}}</blockquote>
|
||||
{{#if sender_email}}<p>Just reply to this email to respond to them directly.</p>{{/if}}
|
||||
<p>
|
||||
You're receiving this because user feedback is enabled for your app. To stop
|
||||
receiving these emails, turn off feedback for the app (e.g.
|
||||
|
||||
@@ -456,12 +456,14 @@ describe('AppFeedbackService owner email', () => {
|
||||
.mockResolvedValue(undefined);
|
||||
};
|
||||
|
||||
it('emails the confirmed owner and marks the row emailed', async () => {
|
||||
it('emails the confirmed owner with the verified sender email + reply-to', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
await confirmOwnerEmail(ownerId);
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor, userId } = await makeUser();
|
||||
// A verified sender email is what gets shared and used as reply-to.
|
||||
await confirmOwnerEmail(userId);
|
||||
const sender = (await server.stores.user.getById(userId))!;
|
||||
|
||||
await submit(actor, { app: app.name, message: 'hello dev' });
|
||||
@@ -474,9 +476,11 @@ describe('AppFeedbackService owner email', () => {
|
||||
expect.objectContaining({
|
||||
owner_username: owner.username,
|
||||
sender_username: sender.username,
|
||||
sender_email: sender.email,
|
||||
app_name: app.name,
|
||||
message: 'hello dev',
|
||||
}),
|
||||
expect.objectContaining({ replyTo: sender.email }),
|
||||
);
|
||||
|
||||
const rows = (await server.clients.db.read(
|
||||
@@ -486,6 +490,22 @@ describe('AppFeedbackService owner email', () => {
|
||||
expect(Boolean(rows[0]?.email_sent)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not share an unverified sender email (no reply-to)', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
await confirmOwnerEmail(ownerId);
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
// Sender's email is left unverified (makeUser does not confirm it).
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await submit(actor, { app: app.name, message: 'hello dev' });
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const [, , values, options] = send.mock.calls[0];
|
||||
expect((values as Record<string, unknown>).sender_email).toBeNull();
|
||||
expect((options as { replyTo?: string } | undefined)?.replyTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores but does not email when the owner email is unconfirmed', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
|
||||
@@ -278,21 +278,31 @@ export class AppFeedbackService extends PuterService {
|
||||
}
|
||||
|
||||
const sender = await this.stores.user.getById(senderUserId);
|
||||
// Share the sender's email so the developer can respond — but only
|
||||
// when it's verified. An unverified address can be anyone's (typed at
|
||||
// signup, never proven), so using it as Reply-To would let a sender
|
||||
// point the developer's reply at a stranger's inbox. Unverified
|
||||
// senders still get their feedback delivered, just without a
|
||||
// reply path. The dialog tells the user their email will be shared.
|
||||
const senderEmail =
|
||||
sender?.email && sender.email_confirmed ? sender.email : null;
|
||||
|
||||
await this.clients.email.send(owner.email, 'app-user-feedback', {
|
||||
owner_username: owner.username,
|
||||
// The sender's username is already visible to the app itself
|
||||
// (puter.auth.getUser), so surfacing it here discloses nothing
|
||||
// new — and the dialog tells the user it will be shared. The
|
||||
// sender's email is never included.
|
||||
sender_username: sender?.username ?? 'A Puter user',
|
||||
// Collapse whitespace so a crafted title can't break the
|
||||
// subject header or spoof extra lines in the body.
|
||||
app_title: String(app.title ?? app.name).replace(/\s+/g, ' '),
|
||||
app_name: String(app.name),
|
||||
app_link: `${this.config.origin}/app/${encodeURIComponent(String(app.name))}`,
|
||||
message,
|
||||
});
|
||||
await this.clients.email.send(
|
||||
owner.email,
|
||||
'app-user-feedback',
|
||||
{
|
||||
owner_username: owner.username,
|
||||
sender_username: sender?.username ?? 'A Puter user',
|
||||
sender_email: senderEmail,
|
||||
// Collapse whitespace so a crafted title can't break the
|
||||
// subject header or spoof extra lines in the body.
|
||||
app_title: String(app.title ?? app.name).replace(/\s+/g, ' '),
|
||||
app_name: String(app.name),
|
||||
app_link: `${this.config.origin}/app/${encodeURIComponent(String(app.name))}`,
|
||||
message,
|
||||
},
|
||||
senderEmail ? { replyTo: senderEmail } : {},
|
||||
);
|
||||
|
||||
await this.stores.appFeedback.markEmailSent(feedbackId);
|
||||
}
|
||||
|
||||
@@ -1424,10 +1424,6 @@ const ipc_listener = async (event, handled) => {
|
||||
sent = await UIWindowAppFeedback({
|
||||
app: app_uuid || app_name,
|
||||
source: 'app',
|
||||
window_options: {
|
||||
parent_uuid: event.data.appInstanceID,
|
||||
disable_parent_window: true,
|
||||
},
|
||||
});
|
||||
} catch ( e ) {
|
||||
console.error('IPC showFeedbackDialog failed', e);
|
||||
|
||||
@@ -4611,16 +4611,19 @@ function attach_dashboard_app_drawer (el_window, options) {
|
||||
const feedback_enabled = options.feedback_enabled === true
|
||||
|| options.feedback_enabled === 1;
|
||||
const feedback_label = i18n('app_feedback_title');
|
||||
// A message/comment glyph (bubble with text lines) — clearer at this size
|
||||
// than a bare speech bubble, which reads as a magnifier.
|
||||
const feedback_btn = feedback_enabled ? `
|
||||
<button type="button" class="dashboard-app-drawer-btn dashboard-app-drawer-feedback" title="${feedback_label}" aria-label="${feedback_label}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11.5a7.5 7.5 0 0 1-10.9 6.7L4 19.5l1.3-4.1A7.5 7.5 0 1 1 20 11.5Z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="7.5" y1="9" x2="16.5" y2="9"/><line x1="7.5" y1="12.5" x2="13" y2="12.5"/></svg>
|
||||
</button>` : '';
|
||||
|
||||
// The toggle comes FIRST in the DOM so Tab reaches it before the
|
||||
// controls' buttons; both layers are absolutely positioned (see
|
||||
// dashboard.css), so DOM order doesn't affect the visuals.
|
||||
// dashboard.css), so DOM order doesn't affect the visuals. `has-feedback`
|
||||
// widens the surface so the extra control doesn't clip the close button.
|
||||
const $drawer = $(`
|
||||
<div class="dashboard-app-drawer collapsed">
|
||||
<div class="dashboard-app-drawer collapsed${feedback_enabled ? ' has-feedback' : ''}">
|
||||
<button type="button" class="dashboard-app-drawer-toggle" aria-expanded="false" title="App controls" aria-label="App controls">
|
||||
<span class="dashboard-app-drawer-grabber" aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -4763,10 +4766,6 @@ function attach_dashboard_app_drawer (el_window, options) {
|
||||
UIWindowAppFeedback({
|
||||
app: options.app_uuid || app_name,
|
||||
source: 'app',
|
||||
window_options: {
|
||||
parent_uuid: options.element_uuid,
|
||||
disable_parent_window: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,30 +17,30 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
|
||||
// Keep in sync with AppFeedbackService.MESSAGE_MAX_LENGTH on the backend.
|
||||
const MESSAGE_MAX_LENGTH = 4000;
|
||||
const SUCCESS_AUTOCLOSE_MS = 1600;
|
||||
|
||||
let feedback_modal_seq = 0;
|
||||
|
||||
/**
|
||||
* "Send feedback to this app's developer" dialog, behind
|
||||
* "Send feedback to this app's developer" modal, behind
|
||||
* `puter.ui.showFeedbackDialog()`. Not to be confused with UIWindowFeedback,
|
||||
* which is Puter's own Contact Us form.
|
||||
*
|
||||
* Deliberately NOT a UIWindow: it's a from-scratch, theme-aware overlay in
|
||||
* the same spirit as the dashboard modals (uninstall, add-app), so it renders
|
||||
* consistently in every context it's opened from — the desktop (app IPC), the
|
||||
* dashboard app-drawer, and the standalone puter.com popup.
|
||||
*
|
||||
* The target is named by exactly one of `options.app` (app uid or name — the
|
||||
* desktop IPC path knows which app asked) or `options.origin` (the popup
|
||||
* path's browser-attested opener origin). The dialog pre-flights the target
|
||||
* desktop/drawer paths know which app asked) or `options.origin` (the popup
|
||||
* path's browser-attested opener origin). The modal pre-flights the target
|
||||
* against `GET /app-feedback/target` — feedback is opt-in per app, and the
|
||||
* server is the authority on the app's canonical title — then submits to
|
||||
* `POST /app-feedback`.
|
||||
*
|
||||
* @param {{
|
||||
* app?: string,
|
||||
* origin?: string,
|
||||
* source?: 'app' | 'web',
|
||||
* window_options?: object,
|
||||
* }} options
|
||||
* @param {{ app?: string, origin?: string, source?: 'app' | 'web' }} options
|
||||
* @returns {Promise<boolean>} true iff feedback was submitted successfully.
|
||||
* Resolves false on cancel/close/unavailable — never rejects, so IPC and
|
||||
* popup callers can always report an answer.
|
||||
@@ -49,205 +49,188 @@ async function UIWindowAppFeedback (options) {
|
||||
options = options ?? {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modal_id = `app-feedback-${++feedback_modal_seq}`;
|
||||
let settled = false;
|
||||
let sending = false;
|
||||
let el_window;
|
||||
let closed = false;
|
||||
|
||||
const authToken = puter.authToken ?? window.auth_token;
|
||||
const target_params = options.app
|
||||
? { app: options.app }
|
||||
: { origin: options.origin };
|
||||
|
||||
const titleId = `${modal_id}-title`;
|
||||
const h = `
|
||||
<div class="app-feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-modal" tabindex="-1">
|
||||
<div class="app-feedback-head">
|
||||
<h2 class="app-feedback-title" id="${titleId}">${i18n('app_feedback_title')}</h2>
|
||||
<button type="button" class="app-feedback-x" aria-label="${html_encode(i18n('close'))}" title="${html_encode(i18n('close'))}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-feedback-body">
|
||||
<div class="app-feedback-loading">${i18n('loading')}…</div>
|
||||
|
||||
<div class="app-feedback-unavailable" style="display:none;">
|
||||
<p class="app-feedback-unavailable-message"></p>
|
||||
<div class="app-feedback-actions">
|
||||
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-close-btn">${i18n('close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-feedback-form" style="display:none;">
|
||||
<div class="app-feedback-target">
|
||||
<div class="app-feedback-target-title"></div>
|
||||
<div class="app-feedback-target-name"></div>
|
||||
</div>
|
||||
<p class="app-feedback-c2a">${i18n('app_feedback_c2a')}</p>
|
||||
<textarea class="app-feedback-message" maxlength="${MESSAGE_MAX_LENGTH}" placeholder="${html_encode(i18n('app_feedback_placeholder'))}"></textarea>
|
||||
<div class="app-feedback-counter">0 / ${MESSAGE_MAX_LENGTH}</div>
|
||||
<p class="app-feedback-note">${i18n('app_feedback_privacy_note')}</p>
|
||||
<p class="app-feedback-error" role="alert" style="display:none;"></p>
|
||||
<div class="app-feedback-actions">
|
||||
<button type="button" class="app-feedback-btn app-feedback-cancel-btn">${i18n('cancel')}</button>
|
||||
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-send-btn" disabled>${i18n('send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-feedback-success" style="display:none;">
|
||||
<div class="app-feedback-success-check" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<p class="app-feedback-success-text">${i18n('app_feedback_sent')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const $overlay = $(h);
|
||||
$('body').append($overlay);
|
||||
// Kick the entrance transition on the next frame.
|
||||
requestAnimationFrame(() => $overlay.addClass('app-feedback-open'));
|
||||
|
||||
const settle = (sent) => {
|
||||
if ( settled ) return;
|
||||
settled = true;
|
||||
resolve(sent === true);
|
||||
};
|
||||
const close = () => {
|
||||
if ( closed ) return;
|
||||
closed = true;
|
||||
$(document).off(`keydown.${modal_id}`);
|
||||
$overlay.removeClass('app-feedback-open');
|
||||
// Let the exit transition play, then remove.
|
||||
setTimeout(() => $overlay.remove(), 160);
|
||||
settle(false); // no-op if already settled true on success
|
||||
};
|
||||
|
||||
// The setup below is async; a synchronous executor with this backstop
|
||||
// guarantees the promise settles even if UIWindow (or anything else
|
||||
// before the on_close handler is wired) throws — the IPC caller
|
||||
// awaits this promise and must always get an answer.
|
||||
const showPane = (pane) => {
|
||||
$overlay.find('.app-feedback-loading, .app-feedback-unavailable, .app-feedback-form, .app-feedback-success').hide();
|
||||
$overlay.find(`.app-feedback-${pane}`).show();
|
||||
};
|
||||
const showUnavailable = (messageKey) => {
|
||||
$overlay.find('.app-feedback-unavailable-message').text(i18n(messageKey));
|
||||
showPane('unavailable');
|
||||
};
|
||||
|
||||
// Escape closes (unless a submit is in flight); backdrop click closes.
|
||||
$(document).on(`keydown.${modal_id}`, (e) => {
|
||||
if ( e.key === 'Escape' && ! sending ) close();
|
||||
});
|
||||
$overlay.on('mousedown', (e) => {
|
||||
if ( e.target === $overlay.get(0) && ! sending ) close();
|
||||
});
|
||||
$overlay.find('.app-feedback-x, .app-feedback-close-btn, .app-feedback-cancel-btn').on('click', close);
|
||||
|
||||
$overlay.find('.app-feedback-message').on('input', function () {
|
||||
$overlay.find('.app-feedback-counter').text(`${this.value.length} / ${MESSAGE_MAX_LENGTH}`);
|
||||
$overlay.find('.app-feedback-send-btn').prop('disabled', sending || this.value.trim() === '');
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
const $btn = $overlay.find('.app-feedback-send-btn');
|
||||
const message = String($overlay.find('.app-feedback-message').val() || '').trim();
|
||||
if ( ! message || sending ) return;
|
||||
sending = true;
|
||||
$btn.prop('disabled', true);
|
||||
$overlay.find('.app-feedback-error').hide();
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/app-feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...target_params,
|
||||
message,
|
||||
context: options.source,
|
||||
}),
|
||||
});
|
||||
if ( ! res.ok ) {
|
||||
let code;
|
||||
try {
|
||||
code = (await res.json())?.code;
|
||||
} catch ( _e ) {
|
||||
// Non-JSON error body; fall through to the generic message.
|
||||
}
|
||||
if ( code === 'feedback_not_enabled' ) {
|
||||
showUnavailable('app_feedback_not_available');
|
||||
return;
|
||||
}
|
||||
const key = res.status === 429 ? 'app_feedback_rate_limited' : 'app_feedback_error';
|
||||
$overlay.find('.app-feedback-error').text(i18n(key)).show();
|
||||
return;
|
||||
}
|
||||
settle(true);
|
||||
showPane('success');
|
||||
setTimeout(close, SUCCESS_AUTOCLOSE_MS);
|
||||
} catch ( e ) {
|
||||
// Shown inside the form so the message survives for a retry.
|
||||
console.error('app-feedback: submit failed', e);
|
||||
$overlay.find('.app-feedback-error').text(i18n('app_feedback_error')).show();
|
||||
} finally {
|
||||
sending = false;
|
||||
$overlay.find('.app-feedback-send-btn').prop('disabled',
|
||||
settled || String($overlay.find('.app-feedback-message').val() || '').trim() === '');
|
||||
}
|
||||
};
|
||||
|
||||
$overlay.find('.app-feedback-send-btn').on('click', send);
|
||||
// Enter alone belongs to the textarea (feedback may need paragraphs).
|
||||
$overlay.find('.app-feedback-message').on('keydown', (e) => {
|
||||
if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) ) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
|
||||
// -- Pre-flight: is this app accepting feedback, and what is it
|
||||
// called? The server is the authority — a title passed by the caller
|
||||
// could impersonate another app.
|
||||
(async () => {
|
||||
const authToken = puter.authToken ?? window.auth_token;
|
||||
const target_params = options.app
|
||||
? { app: options.app }
|
||||
: { origin: options.origin };
|
||||
|
||||
let h = '';
|
||||
h += '<div class="app-feedback-dialog" style="padding: 20px;">';
|
||||
// loading pane
|
||||
h += `<div class="app-feedback-loading" style="text-align:center; padding: 30px 10px; color: #5a6b7b;">${i18n('loading')}…</div>`;
|
||||
// unavailable / error pane
|
||||
h += '<div class="app-feedback-unavailable" style="display:none;">';
|
||||
h += '<p class="app-feedback-unavailable-message" style="text-align:center; padding: 10px; margin-top: 5px;"></p>';
|
||||
h += `<button class="button button-block app-feedback-close-btn" style="margin-bottom: 5px;">${i18n('close')}</button>`;
|
||||
h += '</div>';
|
||||
// form pane
|
||||
h += '<div class="app-feedback-form" style="display:none;">';
|
||||
h += '<div style="margin-bottom: 15px;">';
|
||||
h += '<div class="app-feedback-target-title" style="font-size: 16px; font-weight: 500; word-break: break-word;"></div>';
|
||||
// The unique, format-restricted app name is shown under the free-form
|
||||
// title so one app can't pose as another by copying its title.
|
||||
h += '<div class="app-feedback-target-name" style="font-size: 12px; color: #7a8a9a; word-break: break-all;"></div>';
|
||||
h += '</div>';
|
||||
h += `<p style="margin-top: 0; font-size: 14px; -webkit-font-smoothing: antialiased;">${i18n('app_feedback_c2a')}</p>`;
|
||||
h += `<textarea class="app-feedback-message" maxlength="${MESSAGE_MAX_LENGTH}" placeholder="${html_encode(i18n('app_feedback_placeholder'))}" style="width:100%; height: 150px; padding: 10px; box-sizing: border-box; resize: vertical;"></textarea>`;
|
||||
h += `<div class="app-feedback-counter" style="text-align: right; font-size: 11px; color: #7a8a9a; margin-top: 2px;">0 / ${MESSAGE_MAX_LENGTH}</div>`;
|
||||
h += `<p style="font-size: 12px; color: #7a8a9a; margin: 10px 0;">${i18n('app_feedback_privacy_note')}</p>`;
|
||||
h += `<p class="app-feedback-error" role="alert" style="display:none; color: #b0355a; font-size: 13px; margin: 10px 0;"></p>`;
|
||||
h += '<div style="overflow: hidden; margin-top: 10px;">';
|
||||
h += `<button class="button button-primary app-feedback-send-btn" style="float: right;" disabled>${i18n('send')}</button>`;
|
||||
h += `<button class="button button-default app-feedback-cancel-btn" style="float: right; margin-right: 10px;">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
// success pane
|
||||
h += '<div class="app-feedback-success" style="display:none;">';
|
||||
h += `<img src="${html_encode(window.icons['c-check.svg'])}" style="width:50px; height:50px; display: block; margin:10px auto;">`;
|
||||
h += `<p style="text-align:center; margin-bottom:10px; color: #005300; padding: 10px;">${i18n('app_feedback_sent')}</p>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
|
||||
el_window = await UIWindow({
|
||||
title: i18n('app_feedback_title'),
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: true,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: false,
|
||||
allow_user_select: false,
|
||||
width: 380,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
...options.window_options,
|
||||
on_close: () => {
|
||||
$(document).off(`keydown.app-feedback-${win_id}`);
|
||||
settle(false);
|
||||
},
|
||||
window_class: 'window-app-feedback',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
});
|
||||
const win_id = $(el_window).attr('data-id');
|
||||
|
||||
const showPane = (pane) => {
|
||||
$(el_window).find('.app-feedback-loading, .app-feedback-unavailable, .app-feedback-form, .app-feedback-success').hide();
|
||||
$(el_window).find(`.app-feedback-${pane}`).show();
|
||||
};
|
||||
const showUnavailable = (messageKey) => {
|
||||
$(el_window).find('.app-feedback-unavailable-message').text(i18n(messageKey));
|
||||
showPane('unavailable');
|
||||
};
|
||||
|
||||
// Escape closes unless a submit is in flight. The global Escape
|
||||
// handler in keyboard.js skips windows while a textarea has focus,
|
||||
// so the dialog needs its own (namespaced, removed in on_close).
|
||||
$(document).on(`keydown.app-feedback-${win_id}`, (e) => {
|
||||
if ( e.key !== 'Escape' || sending ) return;
|
||||
if ( ! $(el_window).hasClass('window-active') ) return;
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
$(el_window).find('.app-feedback-close-btn, .app-feedback-cancel-btn').on('click', () => {
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
$(el_window).find('.app-feedback-message').on('input', function () {
|
||||
$(el_window).find('.app-feedback-counter').text(`${this.value.length} / ${MESSAGE_MAX_LENGTH}`);
|
||||
$(el_window).find('.app-feedback-send-btn').prop('disabled', sending || this.value.trim() === '');
|
||||
});
|
||||
|
||||
// -- Pre-flight: is this app accepting feedback, and what is it
|
||||
// called? The server is the authority — a title passed by the caller
|
||||
// could impersonate another app.
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/app-feedback/target?${new URLSearchParams(target_params)}`, {
|
||||
headers: { 'Authorization': `Bearer ${authToken}` },
|
||||
});
|
||||
if ( ! res.ok ) throw new Error(`target check responded ${res.status}`);
|
||||
const target = await res.json();
|
||||
if ( closed ) return;
|
||||
if ( ! target.enabled ) {
|
||||
showUnavailable('app_feedback_not_available');
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.app-feedback-target-title').text(target.app?.title ?? '');
|
||||
$(el_window).find('.app-feedback-target-name').text(target.app?.name ?? '');
|
||||
$overlay.find('.app-feedback-target-title').text(target.app?.title ?? '');
|
||||
$overlay.find('.app-feedback-target-name').text(target.app?.name ?? '');
|
||||
showPane('form');
|
||||
$(el_window).find('.app-feedback-message').get(0)?.focus({ preventScroll: true });
|
||||
$overlay.find('.app-feedback-message').get(0)?.focus({ preventScroll: true });
|
||||
} catch ( e ) {
|
||||
console.error('app-feedback: target check failed', e);
|
||||
showUnavailable('app_feedback_error');
|
||||
return;
|
||||
if ( ! closed ) showUnavailable('app_feedback_error');
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const $btn = $(el_window).find('.app-feedback-send-btn');
|
||||
const message = String($(el_window).find('.app-feedback-message').val() || '').trim();
|
||||
if ( ! message || sending ) return;
|
||||
sending = true;
|
||||
$btn.prop('disabled', true);
|
||||
$(el_window).find('.app-feedback-error').hide();
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/app-feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...target_params,
|
||||
message,
|
||||
context: options.source,
|
||||
}),
|
||||
});
|
||||
if ( ! res.ok ) {
|
||||
let code;
|
||||
try {
|
||||
code = (await res.json())?.code;
|
||||
} catch ( _e ) {
|
||||
// Non-JSON error body; fall through to the generic message.
|
||||
}
|
||||
if ( code === 'feedback_not_enabled' ) {
|
||||
showUnavailable('app_feedback_not_available');
|
||||
return;
|
||||
}
|
||||
const key = res.status === 429 ? 'app_feedback_rate_limited' : 'app_feedback_error';
|
||||
$(el_window).find('.app-feedback-error').text(i18n(key)).show();
|
||||
return;
|
||||
}
|
||||
showPane('success');
|
||||
settle(true);
|
||||
setTimeout(() => $(el_window).close(), SUCCESS_AUTOCLOSE_MS);
|
||||
} catch ( e ) {
|
||||
// Shown inside the form so the message survives for a retry.
|
||||
console.error('app-feedback: submit failed', e);
|
||||
$(el_window).find('.app-feedback-error').text(i18n('app_feedback_error')).show();
|
||||
} finally {
|
||||
sending = false;
|
||||
$(el_window).find('.app-feedback-send-btn').prop('disabled',
|
||||
settled || String($(el_window).find('.app-feedback-message').val() || '').trim() === '');
|
||||
}
|
||||
};
|
||||
|
||||
$(el_window).find('.app-feedback-send-btn').on('click', send);
|
||||
// Enter alone belongs to the textarea (feedback may need paragraphs).
|
||||
$(el_window).find('.app-feedback-message').on('keydown', (e) => {
|
||||
if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) ) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
})().catch((e) => {
|
||||
console.error('app-feedback: dialog failed to open', e);
|
||||
try { $(el_window).close(); } catch ( _e ) {}
|
||||
settle(false);
|
||||
});
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1523,6 +1523,273 @@ input.myapps-group-name:focus {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* App feedback modal (puter.ui.showFeedbackDialog) — a from-scratch,
|
||||
theme-aware overlay in the spirit of the modals above, but used from more
|
||||
than the dashboard (the desktop app-IPC path and the standalone popup too).
|
||||
Self-contained color tokens (light default + dark override) so it themes
|
||||
correctly wherever it's mounted, independent of the dashboard variables. */
|
||||
.app-feedback-overlay {
|
||||
--afb-bg: #ffffff;
|
||||
--afb-fg: #16202b;
|
||||
--afb-muted: #64748b;
|
||||
--afb-border: rgba(15, 23, 42, 0.12);
|
||||
--afb-field-bg: #ffffff;
|
||||
--afb-field-border: rgba(15, 23, 42, 0.18);
|
||||
--afb-hover: rgba(15, 23, 42, 0.05);
|
||||
--afb-scrim: rgba(0, 0, 0, 0.5);
|
||||
--afb-error: #c62828;
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above app windows, the popup shell, and the drawer. */
|
||||
z-index: 2147483000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background: var(--afb-scrim);
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.app-feedback-overlay.app-feedback-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.app-feedback-overlay {
|
||||
--afb-bg: #24262c;
|
||||
--afb-fg: #e8ecf1;
|
||||
--afb-muted: #9aa7b4;
|
||||
--afb-border: rgba(255, 255, 255, 0.12);
|
||||
--afb-field-bg: #1b1d22;
|
||||
--afb-field-border: rgba(255, 255, 255, 0.16);
|
||||
--afb-hover: rgba(255, 255, 255, 0.08);
|
||||
--afb-scrim: rgba(0, 0, 0, 0.66);
|
||||
--afb-error: #ff6b81;
|
||||
}
|
||||
}
|
||||
|
||||
.app-feedback-modal {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
background: var(--afb-bg);
|
||||
color: var(--afb-fg);
|
||||
border: 1px solid var(--afb-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
|
||||
transform: translateY(8px) scale(0.98);
|
||||
transition: transform 0.16s ease;
|
||||
outline: none;
|
||||
}
|
||||
.app-feedback-open .app-feedback-modal {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-feedback-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 0 20px;
|
||||
}
|
||||
.app-feedback-title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-feedback-x {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--afb-muted);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.app-feedback-x svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.app-feedback-x:hover {
|
||||
background: var(--afb-hover);
|
||||
color: var(--afb-fg);
|
||||
}
|
||||
}
|
||||
|
||||
.app-feedback-body {
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
|
||||
.app-feedback-loading {
|
||||
text-align: center;
|
||||
color: var(--afb-muted);
|
||||
padding: 28px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app-feedback-target {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.app-feedback-target-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
.app-feedback-target-name {
|
||||
font-size: 12px;
|
||||
color: var(--afb-muted);
|
||||
word-break: break-all;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.app-feedback-c2a {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.app-feedback-unavailable-message {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
color: var(--afb-muted);
|
||||
}
|
||||
|
||||
.app-feedback-message {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 130px;
|
||||
resize: vertical;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--afb-fg);
|
||||
background: var(--afb-field-bg);
|
||||
border: 1px solid var(--afb-field-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.app-feedback-message:focus {
|
||||
outline: none;
|
||||
border-color: #088ef0;
|
||||
box-shadow: 0 0 0 3px rgba(8, 142, 240, 0.18);
|
||||
}
|
||||
.app-feedback-message::placeholder {
|
||||
color: var(--afb-muted);
|
||||
}
|
||||
|
||||
.app-feedback-counter {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--afb-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.app-feedback-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--afb-muted);
|
||||
}
|
||||
.app-feedback-error {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--afb-error);
|
||||
}
|
||||
|
||||
.app-feedback-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.app-feedback-btn {
|
||||
padding: 8px 18px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--afb-field-border);
|
||||
background: var(--afb-bg);
|
||||
color: var(--afb-fg);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.app-feedback-btn:hover {
|
||||
background: var(--afb-hover);
|
||||
}
|
||||
}
|
||||
.app-feedback-btn-primary {
|
||||
border-color: #088ef0;
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
color: #fff;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.app-feedback-btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
}
|
||||
}
|
||||
.app-feedback-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.app-feedback-success {
|
||||
text-align: center;
|
||||
padding: 18px 10px 8px;
|
||||
}
|
||||
.app-feedback-success-check {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #16a34a;
|
||||
}
|
||||
.app-feedback-success-check svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.app-feedback-success-text {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Bottom-sheet on narrow screens. */
|
||||
@media (max-width: 500px) {
|
||||
.app-feedback-overlay {
|
||||
padding: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.app-feedback-modal {
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
border-radius: 14px 14px 0 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
.app-feedback-open .app-feedback-modal {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
|
||||
rows in the same modal shell. min-width gives the two-line rows room without
|
||||
overflowing a narrow phone; the shell's own max-width caps the other end. */
|
||||
@@ -5390,6 +5657,13 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* An opted-in app carries a third control (feedback) before minimize; widen
|
||||
the derived open surface by one button + its 2px gap so the trailing close
|
||||
button isn't clipped. --open-w stays derived from the same parts. */
|
||||
.dashboard-app-drawer.has-feedback {
|
||||
--open-w: calc(12px + var(--icon) + var(--i2t) + var(--title-w) + var(--t2b) + var(--btn) + 2px + var(--btn) + 2px + var(--btn) + 10px);
|
||||
}
|
||||
|
||||
.dashboard-app-drawer.collapsed {
|
||||
width: var(--shut-w);
|
||||
height: calc(var(--shut-h) + var(--safe-top));
|
||||
@@ -5574,6 +5848,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.dashboard-app-drawer-feedback,
|
||||
.dashboard-app-drawer-minimize {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const en = {
|
||||
app_feedback_error: 'Something went wrong. Please try again.',
|
||||
app_feedback_not_available: 'This app is not accepting feedback right now.',
|
||||
app_feedback_placeholder: 'What is working well? What could be better?',
|
||||
app_feedback_privacy_note: 'Your username will be shared with the developer so they can follow up.',
|
||||
app_feedback_privacy_note: 'Your email address will be shared with the developer so they can respond.',
|
||||
app_feedback_rate_limited: "You've sent a lot of feedback recently. Please try again later.",
|
||||
app_feedback_sent: 'Feedback sent. Thank you!',
|
||||
app_feedback_title: 'Send Feedback',
|
||||
|
||||
@@ -761,10 +761,6 @@ const postAuthActions = async (action) => {
|
||||
sent = await UIWindowAppFeedback({
|
||||
origin,
|
||||
source: 'web',
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('send-feedback action failed', e);
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('puter.ui.showFeedbackDialog (env=app)', () => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('#send-feedback').click();
|
||||
const dialog = page.locator('.window.window-app-feedback');
|
||||
const dialog = page.locator('.app-feedback-overlay');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The form pane only appears after the server-side target check
|
||||
@@ -73,7 +73,7 @@ test.describe('puter.ui.showFeedbackDialog (env=app)', () => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('#send-feedback').click();
|
||||
const dialog = page.locator('.window.window-app-feedback');
|
||||
const dialog = page.locator('.app-feedback-overlay');
|
||||
await expect(dialog.locator('.app-feedback-form')).toBeVisible({ timeout: 15_000 });
|
||||
await dialog.locator('.app-feedback-cancel-btn').click();
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe('puter.ui.showFeedbackDialog (env=app)', () => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
|
||||
await appFrame.locator('#send-feedback').click();
|
||||
const dialog = page.locator('.window.window-app-feedback');
|
||||
const dialog = page.locator('.app-feedback-overlay');
|
||||
await expect(dialog.locator('.app-feedback-unavailable')).toBeVisible({ timeout: 15_000 });
|
||||
// No form to type into — feedback is strictly opt-in.
|
||||
await expect(dialog.locator('.app-feedback-form')).toBeHidden();
|
||||
@@ -131,7 +131,7 @@ test.describe('dashboard app-drawer feedback control', () => {
|
||||
await feedbackBtn.click();
|
||||
|
||||
// It opens the same dialog, targeting this app.
|
||||
const dialog = page.locator('.window.window-app-feedback');
|
||||
const dialog = page.locator('.app-feedback-overlay');
|
||||
await expect(dialog.locator('.app-feedback-form')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(dialog.locator('.app-feedback-target-name')).toHaveText(appName);
|
||||
} finally {
|
||||
@@ -173,7 +173,7 @@ test.describe('puter.ui.showFeedbackDialog (env=web popup)', () => {
|
||||
// browser-attested opener origin resolves to. On a shared dev DB
|
||||
// that app is not deterministic, so this asserts the dialog shell
|
||||
// rather than a specific pane.
|
||||
const dialog = popup.locator('.window.window-app-feedback');
|
||||
const dialog = popup.locator('.app-feedback-overlay');
|
||||
await expect(dialog).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Closing the popup without submitting reports a dismissal.
|
||||
|
||||
Reference in New Issue
Block a user