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:
Nariman Jelveh
2026-08-11 17:11:36 -07:00
parent 32c950de5b
commit 81402918d2
11 changed files with 511 additions and 225 deletions
+7 -1
View File
@@ -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 } : {}),
});
}
+2 -1
View File
@@ -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);
}
-4
View File
@@ -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);
+6 -7
View File
@@ -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,
},
});
});
+170 -187
View File
@@ -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);
});
})();
});
}
+275
View File
@@ -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;
}
+1 -1
View File
@@ -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',
-4
View File
@@ -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.