From 57b4cbab668dac968d7b08f9c19360add003d0a6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 18 Aug 2026 17:34:40 -0700 Subject: [PATCH] feat(dashboard): rewrite Files-tab sharing as a from-scratch modal (#3598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): rewrite Files-tab sharing as a from-scratch modal The Files tab's Share… action opened UIWindowShare, a desktop UIWindow, which doesn't fit the dashboard. Replace it with UIShareModal, built on the same overlay pattern as UIItemPropertiesModal: a centered card on desktop, a bottom sheet on mobile, styled with the dashboard's design tokens (dark mode included). Wiring follows the onShowProperties precedent: generate_file_context_menu gains an optional onShare hook that TabFiles supplies; every other caller falls back to the desktop share window unchanged. Feature parity with UIWindowShare — grant by email/username with an access level, list who has access (owner, direct, and inherited-from- ancestor grants), change a grant's mode, revoke — plus: - revoke confirmation inlined into the row instead of a stacked UIAlert - real form semantics: Enter submits, button disabled until input, spinner while in flight, API errors keep the typed name for correction - dialog a11y: focus moves in on open and back on close, Tab is trapped, Escape closes (first Escape cancels an open revoke confirmation), status messages announce via aria-live - backdrop close keyed on where the press started, so a text-selection drag out of the input can't dismiss the dialog - name-derived avatar colors and a loading state for the first fetch Also fixes a double-encoding bug (shared by UIWindowShare) where mode labels ran through html_encode twice, rendering "Can edit & share". * fix(dashboard): keep keyboard focus inside the share modal across re-renders Changing a grant's mode, revoking, or Escape-canceling the inline confirmation re-renders the access list (or disables the focused control), which dropped focus onto - outside the dialog's Tab trap and invisible to screen readers. Focus is now restored explicitly after each of those actions: to the same holder's control when it survives, else to the dialog itself (tabindex=-1), and the Tab trap steps back into the cycle when focus rests on the dialog container. A failed grant likewise returns focus to the recipient input. * fix(dashboard): give share modal controls 44px touch targets On touch devices the close (32x32) and revoke (30x30) buttons and the 26px-tall per-row mode select were below the 44px minimum. On coarse pointers the icon buttons now hit-test at 44x44 through a centered pseudo-element (visuals unchanged, and the row spacing absorbs the overhang without overlapping neighbors), and the row select gets taller padding. Fine-pointer rendering is untouched. * fix(dashboard): clear the iOS home indicator in the share bottom sheet The app opts into viewport-fit=cover, so on notched phones the bottom-docked sheet extends to the physical screen edge and its last row sat under the home indicator. The sheet body's bottom padding now adds env(safe-area-inset-bottom), matching how the codebase pads other bottom-docked surfaces. * fix(share): say 'Updated access' when changing a grant's mode Changing someone's access level reported 'Shared with X', the same message as a fresh grant, which reads as if a new share happened. Both share dialogs now confirm mode changes with a dedicated 'Updated access for X' message. The UIWindowShare call also drops the html_encode() around the recipient - i18n() already encodes interpolations, so the wrapper double-encoded. * fix(dashboard): align the share dialog's accessible name with the properties modal The dialog's aria-label read 'name - Share' with an em dash while the sibling properties modal uses plain 'name Properties'; screen readers announce the dash as noise. Use the same name-then-noun pattern. * fix(dashboard): ellipsize the share input's placeholder when it overflows At phone widths the 'Add people by email or username' placeholder was clipped mid-letter; text-overflow: ellipsis truncates it cleanly. * fix(dashboard): raise the share selects' chevron contrast in light mode The hardcoded slate-400 stroke measured ~2.6:1 against light surfaces, below the 3:1 minimum for non-text indicators. Light mode now uses slate-500 (~4.7:1); dark mode keeps slate-400, which already clears 5:1 there. * fix(dashboard): stop Files-tab shortcuts from firing behind the share modal The document-level keydown.tabfiles handler kept running while the share (or item-properties) modal was open. With focus on any of the modal's buttons or selects, Enter and Space were preventDefault-ed before they could activate the control, arrows could not drive the mode selects, and letter typeahead was hijacked into row typesearch — while Enter opened the selected row behind the overlay, Delete moved it to Trash, and Cmd+A/C/X/V acted on the hidden list. Yield the keyboard to the modal for as long as one is up; its own handlers already cover Escape and Tab. * fix(dashboard): keep the on-screen keyboard down when the share sheet opens Autofocusing the recipient input popped the keyboard over the bottom sheet the moment it opened, hiding the access list before the user had chosen what to do — the same reason the revoke flow already focuses the dialog instead of the input. On touch-primary devices give the dialog container initial focus (which also anchors the Tab trap); desktop keeps the input autofocus. * fix(dashboard): give the share sheet's confirm and submit buttons 44px touch height The inline revoke confirmation's Cancel/Remove pair rendered ~29px tall on touch — small targets 6px apart where one of the two is destructive — and the submit button was fixed at 38px. Grow both to 44px under pointer: coarse, matching the standard the modal's icon buttons already meet. The touch block moves below the confirm-button base rules it now overrides, since a media query adds no specificity and source order decides. * fix(dashboard): name the person in the share rows' accessible labels Every grant row's mode select announced as bare 'Access level' and every revoke button as 'Remove access', so a screen reader user tabbing the list could not tell whose grant a control changes. Carry the holder in the aria-label ('Access level for alice' / 'Remove access for alice'); the visible UI and the revoke tooltip stay as they were. i18n() encodes the interpolated string as a whole, quotes included, so the labels stay attribute-safe for any holder name. * fix(dashboard): keep the share status region in the accessibility tree * fix(dashboard): give the share modal's add-row controls 44px touch height * refactor(gui): extract the share dialogs' pure logic into tested modules * feat(dashboard): adopt the shared mode helpers and handle pending invitations --------- Co-authored-by: Juan Castro --- src/backend/types.ts | 10 +- src/gui/src/UI/Dashboard/TabFiles.js | 17 + src/gui/src/UI/Dashboard/UIShareModal.js | 412 +++++++++++++ src/gui/src/UI/Dashboard/shareAvatar.js | 47 ++ src/gui/src/UI/Dashboard/shareAvatar.test.js | 51 ++ src/gui/src/UI/UIWindowShare.js | 28 +- src/gui/src/css/dashboard.css | 567 ++++++++++++++++++ .../src/helpers/generate_file_context_menu.js | 13 + src/gui/src/helpers/share_modes.js | 58 ++ src/gui/src/helpers/share_modes.test.js | 70 +++ src/gui/src/i18n/translations/en.js | 5 + 11 files changed, 1245 insertions(+), 33 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/UIShareModal.js create mode 100644 src/gui/src/UI/Dashboard/shareAvatar.js create mode 100644 src/gui/src/UI/Dashboard/shareAvatar.test.js create mode 100644 src/gui/src/helpers/share_modes.js create mode 100644 src/gui/src/helpers/share_modes.test.js diff --git a/src/backend/types.ts b/src/backend/types.ts index 975a5e074..aec8253ee 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -239,12 +239,7 @@ export interface IPreludeConfig { * an RCS agent provisioned in the Prelude account to actually use RCS. */ preferredChannel?: - | 'sms' - | 'rcs' - | 'whatsapp' - | 'viber' - | 'zalo' - | 'telegram'; + 'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram'; } /** @@ -1108,7 +1103,8 @@ export interface WithLifecycle extends Object { } export interface WithCostsReporting extends WithLifecycle { - getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any + getReportedCosts?: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any | Promise[]> // eslint-disable-next-line @typescript-eslint/no-explicit-any | Record[]; diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 07c9ff016..9acb2114e 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -33,6 +33,7 @@ import new_context_menu_item from '../../helpers/new_context_menu_item.js'; import publish_as_website from '../../helpers/publish_as_website.js'; import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import UIItemPropertiesModal from './UIItemPropertiesModal.js'; +import UIShareModal from './UIShareModal.js'; import { dedupedName } from './dedupedName.js'; import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js'; @@ -663,6 +664,12 @@ const TabFiles = { // Only handle if Dashboard Files tab is active if ( ! _this.isDashboardFilesActive() ) return; + // A Files-tab modal (share, item properties) owns the keyboard + // while open: Enter/Space must reach its buttons, arrows its + // selects, and typing must not retarget row selection — nor may + // Enter/Delete open or trash the rows behind the overlay. + if ( $('.share-modal-overlay, .item-props-overlay').length > 0 ) return; + const focused_el = document.activeElement; // Skip if user is typing in an input/textarea (except for Escape) @@ -3882,6 +3889,16 @@ const TabFiles = { $container: _this.$el_window, }); }, + onShare: ({ name, path: item_path }) => { + // Dashboard uses a responsive modal instead of the desktop UIWindow. + UIShareModal({ + name, + path: item_path, + // The row's fs entry, so the modal can show the item's icon. + fsentry: options, + $container: _this.$el_window, + }); + }, }); return menu_items; diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js new file mode 100644 index 000000000..9925f0308 --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -0,0 +1,412 @@ +/* + * 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 . + */ + +import path from '../../lib/path.js'; +import item_icon from '../../helpers/item_icon.js'; +import { owner_of_path } from '../../helpers/path_owner.js'; +import { invalidate_shared_roots } from '../../helpers/shared_access.js'; +import { icons } from '../../helpers/actionIcons.js'; +import { mode_label, options_for } from '../../helpers/share_modes.js'; +import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; +import { avatarHue, avatarInitial } from './shareAvatar.js'; + +const { html_encode } = window; + +const closeIcon = ``; + +const avatar_html = (name) => { + return ``; +}; + +/** + * A responsive, from-scratch sharing modal for the Dashboard's Files tab. + * Unlike UIWindowShare (which spawns a desktop UIWindow), this renders a + * self-contained overlay that behaves as a centered card on desktop and a + * bottom sheet on mobile, styled with the dashboard's design tokens. + * + * Feature-equivalent to UIWindowShare: grant access by email or username, + * list who has access (owner, direct grants, grants inherited from an + * ancestor folder), change a grant's mode, and revoke — with the revoke + * confirmation inlined into the row instead of a stacked alert window. + * + * @param {Object} opts + * @param {string} opts.path - Full path of the item to share + * @param {string} [opts.name] - Display name; defaults to the path's basename + * @param {string} [opts.owner] - Owner's username; defaults to the first path + * segment, which is not the current user when a `manage` recipient opens this + * @param {Object} [opts.fsentry] - The item's fs entry, used to render its icon + * @param {jQuery} [opts.$container] - Element to append the overlay to (defaults to ) + * @returns {{ close: () => void }} + */ +export default function UIShareModal ({ path: item_path, name, owner, fsentry, $container }) { + const $root = $container && $container.length ? $container : $('body'); + const item_name = name ?? path.basename(item_path); + const item_owner = owner ?? owner_of_path(item_path) ?? window.user.username; + + const $overlay = $(` + + `); + + $root.append($overlay); + + // Reveal after paint so the CSS transition (fade + scale/slide) runs. + requestAnimationFrame(() => $overlay.addClass('share-modal-show')); + + const $status = $overlay.find('.share-modal-status'); + const $list = $overlay.find('.share-modal-list'); + const $recipient = $overlay.find('.share-modal-recipient'); + const $submit = $overlay.find('.share-modal-submit'); + + // Focus returns to wherever the user was (usually the shared row) when + // the modal closes. While it's up, the recipient input takes it on + // desktop; on touch-primary devices the dialog itself does, because + // focusing the input would pop the on-screen keyboard over the sheet + // before the user has chosen what to do (add, change, or revoke). + const el_previous_focus = document.activeElement; + if ( isTouchPrimaryDevice() ) { + $overlay.find('.share-modal').get(0)?.focus({ preventScroll: true }); + } else { + $recipient.get(0)?.focus({ preventScroll: true }); + } + + let closed = false; + const close = () => { + if ( closed ) return; + closed = true; + $overlay.removeClass('share-modal-show'); + $(document).off('keydown.share-modal'); + setTimeout(() => $overlay.remove(), 200); + if ( el_previous_focus && document.contains(el_previous_focus) ) { + try { + el_previous_focus.focus({ preventScroll: true }); + } catch { /* focus restoration is best-effort */ } + } + }; + + // The last successfully fetched share list, so canceling an inline revoke + // confirmation can restore the row without another network round-trip. + let last_shares = []; + + // Re-rendering the list replaces its nodes wholesale, and disabling a + // focused control drops focus onto — either would strand a + // keyboard user outside the dialog, past the reach of the Tab trap. + // Every action that does one of those puts focus back explicitly: + // on the same holder's control when it survives, else on the dialog. + const focus_list_control = (selector, holder) => { + const $el = $list.find(selector) + .filter((_, el) => $(el).attr('data-holder') === holder); + if ( !$el.length ) return false; + $el.get(0).focus({ preventScroll: true }); + return true; + }; + const focus_dialog = () => { + $overlay.find('.share-modal').get(0)?.focus({ preventScroll: true }); + }; + + // Both take HTML-safe text; `error_html` encodes the one raw source. + const show_error = (html) => { + $status + .removeClass('share-modal-status-success') + .addClass('share-modal-status-error') + .html(html); + }; + + const show_success = (html) => { + $status + .removeClass('share-modal-status-error') + .addClass('share-modal-status-success') + .html(html); + }; + + const error_html = (err) => (err?.message ? html_encode(err.message) : i18n('share_failed')); + + const clear_status = () => { + $status.removeClass('share-modal-status-error share-modal-status-success').empty(); + }; + + const render = (shares) => { + last_shares = shares; + let rows = ''; + + // The owner's access comes from owning the item, so it can't be revoked + rows += ''; + + for ( const share of shares ) { + const holder = html_encode(share.holder ?? ''); + const you_suffix = share.holder === window.user.username ? ` (${i18n('share_you')})` : ''; + if ( share.inheritedFrom ) { + // Granted on an ancestor, so it can only be changed there + rows += ''; + continue; + } + if ( share.pending ) { + // Invited by email with no account yet: nothing to change until they join. + const invited = share.recipientEmail ?? ''; + rows += ''; + continue; + } + rows += ''; + } + if ( !shares.length ) { + rows += ``; + } + $list.attr('aria-busy', 'false').html(rows); + }; + + const refresh = async () => { + try { + render(await puter.fs.getShares(item_path)); + } catch (e) { + $list.attr('aria-busy', 'false').empty(); + show_error(error_html(e)); + } + }; + + // -- Dismissal wiring -- + $overlay.on('click', '.share-modal-close', close); + // Backdrop close goes by where the press STARTED: a drag that begins in + // the recipient input (text selection) and releases over the backdrop + // registers its click on the overlay, and must not eat the typed name. + let backdrop_pressed = false; + $overlay.on('mousedown', function (e) { + backdrop_pressed = e.target === $overlay[0]; + }); + $overlay.on('click', function (e) { + if ( e.target === $overlay[0] && backdrop_pressed ) close(); + }); + $(document).on('keydown.share-modal', function (e) { + if ( e.key !== 'Escape' ) return; + // An open revoke confirmation swallows the first Escape. + const $confirm = $list.find('.share-modal-row-confirm'); + if ( $confirm.length ) { + const holder = $confirm.attr('data-holder'); + render(last_shares); + focus_list_control('.share-modal-revoke', holder) || focus_dialog(); + return; + } + close(); + }); + + // Keep Tab cycling inside the dialog while it's up. + $overlay.on('keydown', function (e) { + if ( e.key !== 'Tab' ) return; + const focusables = $overlay + .find('button, input, select, [tabindex]:not([tabindex="-1"])') + .filter(':visible:not(:disabled)'); + if ( !focusables.length ) return; + const first = focusables.get(0); + const last = focusables.get(focusables.length - 1); + // Focus can legitimately sit on the dialog container (it takes focus + // after a revoke removes the focused row); step into the cycle from + // either end instead of letting Tab walk out of the dialog. + if ( focusables.index(document.activeElement) === -1 ) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if ( e.shiftKey && document.activeElement === first ) { + e.preventDefault(); + last.focus(); + } else if ( !e.shiftKey && document.activeElement === last ) { + e.preventDefault(); + first.focus(); + } + }); + + // -- Item icon (best-effort) -- + if ( fsentry ) { + (async () => { + try { + const icon = await item_icon(fsentry); + if ( !closed && icon?.image ) { + $overlay.find('.share-modal-title-icon') + .html(``); + } + } catch { /* icon is best-effort */ } + })(); + } + + // -- Grant access -- + $recipient.on('input', function () { + $submit.prop('disabled', $(this).val().trim() === ''); + // Typing again retires a stale success/error message. + clear_status(); + }); + + $overlay.on('submit', '.share-modal-add', async function (e) { + e.preventDefault(); + const recipient = $recipient.val().trim(); + if ( !recipient ) return; + + $submit.prop('disabled', true).addClass('share-modal-btn-busy'); + try { + const created = await puter.fs.share({ + path: item_path, + recipient, + mode: $overlay.find('.share-modal-mode').val(), + }); + // Clear only what we sent; a name typed mid-flight shouldn't vanish. + if ( $recipient.val().trim() === recipient ) $recipient.val(''); + $submit.prop('disabled', $recipient.val().trim() === ''); + // "Shared with" would claim access an invite does not grant. + show_success( + created?.some((share) => share.pending) + ? i18n('share_invited', { recipient }) + : i18n('share_shared_with', { recipient }), + ); + invalidate_shared_roots(); + await refresh(); + $recipient.get(0)?.focus({ preventScroll: true }); + } catch (err) { + show_error(error_html(err)); + $submit.prop('disabled', false); + // Disabling the clicked button dropped focus to ; put it + // where the correction happens. + $recipient.get(0)?.focus({ preventScroll: true }); + } finally { + $submit.removeClass('share-modal-btn-busy'); + } + }); + + // -- Change a grant's mode -- + $overlay.on('change', '.share-modal-row-mode', async function () { + const holder = $(this).attr('data-holder'); + const mode = $(this).val(); + $(this).prop('disabled', true); + try { + await puter.fs.share({ path: item_path, recipient: holder, mode }); + show_success(i18n('share_access_updated', { recipient: holder })); + invalidate_shared_roots(); + await refresh(); + } catch (err) { + show_error(error_html(err)); + invalidate_shared_roots(); + await refresh(); + } + focus_list_control('.share-modal-row-mode', holder) || focus_dialog(); + }); + + // -- Revoke, confirmed inline in the row -- + $overlay.on('click', '.share-modal-revoke', function () { + const holder = $(this).attr('data-holder'); + const enc = html_encode(holder); + // Withdrawing an invitation isn't taking access away; the prompt must say which. + const is_pending = $(this).closest('.share-modal-row').hasClass('share-modal-row-pending'); + // One confirmation at a time: opening a second one restores the first + // row. The re-render replaces every row, so re-find this holder's + // instead of using the (now detached) clicked button. + if ( $list.find('.share-modal-row-confirm').length ) { + render(last_shares); + } + $list.find('.share-modal-revoke') + .filter((_, el) => $(el).attr('data-holder') === holder) + .closest('.share-modal-row') + .replaceWith(` + + `); + $list.find('.share-modal-confirm-cancel').trigger('focus'); + }); + + $overlay.on('click', '.share-modal-confirm-cancel', function () { + const holder = $(this).closest('.share-modal-row-confirm').attr('data-holder'); + render(last_shares); + focus_list_control('.share-modal-revoke', holder) || focus_dialog(); + }); + + $overlay.on('click', '.share-modal-confirm-remove', async function () { + const holder = $(this).attr('data-holder'); + const is_pending = $(this).attr('data-pending') === 'true'; + $(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true); + try { + await puter.fs.unshare(item_path, holder); + show_success(is_pending + ? i18n('share_invite_cancelled', { recipient: holder }) + : i18n('share_access_removed', { recipient: holder })); + invalidate_shared_roots(); + await refresh(); + // The focused row is gone; the dialog itself takes focus (the + // input would pop the on-screen keyboard on touch devices). + focus_dialog(); + } catch (err) { + show_error(error_html(err)); + render(last_shares); + focus_list_control('.share-modal-revoke', holder) || focus_dialog(); + } + }); + + refresh(); + + return { close }; +} diff --git a/src/gui/src/UI/Dashboard/shareAvatar.js b/src/gui/src/UI/Dashboard/shareAvatar.js new file mode 100644 index 000000000..7a77bcb14 --- /dev/null +++ b/src/gui/src/UI/Dashboard/shareAvatar.js @@ -0,0 +1,47 @@ +/* + * 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 . + */ + +// The initial-and-color avatar the share modal shows next to each person. + +/** + * Stable, name-derived hue so a person keeps the same color across reopenings. + * + * @param {string} [name] + * @returns {number} hue in [0, 360) + */ +export const avatarHue = (name) => { + let hue = 0; + for ( const char of String(name ?? '') ) { + hue = (hue * 31 + char.codePointAt(0)) % 360; + } + return hue; +}; + +/** + * The name's first character, uppercased; `?` when there is no name. + * + * @param {string} [name] + * @returns {string} + */ +export const avatarInitial = (name) => { + const trimmed = String(name ?? '').trim(); + // Iterating takes a whole code point; charAt(0) halves an emoji into tofu. + for ( const char of trimmed ) return char.toUpperCase(); + return '?'; +}; diff --git a/src/gui/src/UI/Dashboard/shareAvatar.test.js b/src/gui/src/UI/Dashboard/shareAvatar.test.js new file mode 100644 index 000000000..75965dc53 --- /dev/null +++ b/src/gui/src/UI/Dashboard/shareAvatar.test.js @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { avatarHue, avatarInitial } from './shareAvatar.js'; + +describe('avatarHue', () => { + it('gives the same person the same color every time', () => { + expect(avatarHue('juan')).toBe(avatarHue('juan')); + }); + + it('stays a usable hue', () => { + for ( const name of ['a', 'juan', 'someone@example.com', '𝒥'.repeat(40)] ) { + const hue = avatarHue(name); + expect(Number.isInteger(hue)).toBe(true); + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + } + }); + + it('tells different people apart', () => { + expect(avatarHue('ann')).not.toBe(avatarHue('bob')); + }); + + it('handles a missing name instead of throwing', () => { + expect(avatarHue(undefined)).toBe(0); + expect(avatarHue(null)).toBe(0); + expect(avatarHue('')).toBe(0); + }); +}); + +describe('avatarInitial', () => { + it('uppercases the first letter', () => { + expect(avatarInitial('juan')).toBe('J'); + expect(avatarInitial('Ann')).toBe('A'); + }); + + it('ignores surrounding whitespace', () => { + expect(avatarInitial(' ann ')).toBe('A'); + }); + + it('falls back to ? when there is no name', () => { + // A pending invite has no username until the recipient joins. + expect(avatarInitial('')).toBe('?'); + expect(avatarInitial(' ')).toBe('?'); + expect(avatarInitial(undefined)).toBe('?'); + expect(avatarInitial(null)).toBe('?'); + }); + + it('keeps an astral first character whole', () => { + // charAt(0) would return half a surrogate pair and render as tofu. + expect(avatarInitial('😀 team')).toBe('😀'); + }); +}); diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js index e91f3aff1..1f6986371 100644 --- a/src/gui/src/UI/UIWindowShare.js +++ b/src/gui/src/UI/UIWindowShare.js @@ -23,31 +23,7 @@ import path from '../lib/path.js'; import { owner_of_path } from '../helpers/path_owner.js'; import { invalidate_shared_roots } from '../helpers/shared_access.js'; import { icons } from '../helpers/actionIcons.js'; - -// Offered when granting. The API accepts `see` and `list` too, but they are a -// developer-level distinction with no place in this dialog — a row already set -// to one is shown as-is rather than quietly rounded up to `read`. -const MODES = ['read', 'write', 'manage']; - -// Already HTML-safe: `i18n()` encodes what it returns, and an unencoded mode -// from the API is encoded here. Encoding a label again turns the `&` in -// "Can edit & share" into a literal `&`. -const mode_label = (mode) => { - if ( mode === 'write' ) return i18n('share_access_write'); - if ( mode === 'manage' ) return i18n('share_access_manage'); - if ( mode === 'read' ) return i18n('share_access_read'); - return html_encode(mode); -}; - -const options_for = (current) => { - const modes = MODES.includes(current) ? MODES : [current, ...MODES]; - return modes - .map( - (mode) => - ``, - ) - .join(''); -}; +import { mode_label, options_for } from '../helpers/share_modes.js'; /** * Sharing dialog for one file or directory. @@ -220,7 +196,7 @@ async function UIWindowShare (options) { $(this).prop('disabled', true); try { await puter.fs.share({ path: item_path, recipient: holder, mode }); - show_success(i18n('share_shared_with', { recipient: holder })); + show_success(i18n('share_access_updated', { recipient: holder })); invalidate_shared_roots(); await refresh(); } catch (e) { diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 74510112b..cd3e3a752 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -2254,6 +2254,573 @@ p.myapps-add-error { } } +/* -- Share modal -- + Responsive, from-scratch replacement for UIWindowShare in the Dashboard: + a centered card on desktop, a bottom sheet on mobile. Mirrors the item + properties modal's shell so the two read as one system. */ +.share-modal-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + box-sizing: border-box; + background: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5)); + opacity: 0; + transition: opacity 200ms ease; + z-index: 1000; +} +.share-modal-overlay.share-modal-show { + opacity: 1; +} + +.share-modal { + display: flex; + flex-direction: column; + width: min(440px, 100%); + max-height: min(620px, 85vh); + background: var(--dashboard-card-background); + border: 1px solid var(--dashboard-border); + border-radius: 14px; + box-shadow: 0 12px 40px var(--dashboard-shadow-medium); + overflow: hidden; + transform: scale(0.96); + opacity: 0; + transition: transform 200ms ease, opacity 200ms ease; +} +.share-modal-overlay.share-modal-show .share-modal { + transform: scale(1); + opacity: 1; +} +/* The dialog takes focus programmatically (tabindex=-1) when an action + removes the focused row; that's a resting place, not a target. */ +.share-modal:focus { + outline: none; +} + +/* Header */ +.share-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--dashboard-border); + flex-shrink: 0; +} +.share-modal-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.share-modal-title-icon { + flex-shrink: 0; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; +} +/* Until (and unless) the item's icon resolves, the title sits flush left. */ +.share-modal-title-icon:empty { + display: none; +} +.share-modal-title-icon img { + width: 34px; + height: 34px; + object-fit: contain; +} +.share-modal-title-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.share-modal-title-name { + font-size: 15px; + font-weight: 600; + color: var(--dashboard-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.share-modal-title-sub { + font-size: 12px; + color: var(--dashboard-text-muted); +} +.share-modal-close { + 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; +} +@media (hover: hover) { + .share-modal-close:hover { + background: var(--dashboard-hover); + color: var(--dashboard-text-primary); + } +} +.share-modal-close:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} + +/* Body */ +.share-modal-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 16px 18px 18px; +} + +/* Add people. style.css's input[type=text] / select rules match these + controls at the SAME specificity, so everything must be spelled out here + (twice for focus — input[type=text]:focus sets padding 7px / border 2px). + Same trap input.myapps-group-name documents. */ +.share-modal-add-row { + display: flex; + gap: 8px; +} +input.share-modal-recipient { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + flex: 1 1 auto; + width: auto; + min-width: 0; + /* On narrow screens the placeholder outgrows the field; fade it out + with an ellipsis instead of clipping mid-letter. */ + text-overflow: ellipsis; + padding: 8px 10px; + border: 1px solid var(--dashboard-border); + border-radius: 8px; + background: var(--dashboard-input-background); + font-size: 13.5px; + font-family: inherit; + color: var(--dashboard-text-primary); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +input.share-modal-recipient::placeholder { + color: var(--dashboard-text-muted); +} +input.share-modal-recipient:focus { + padding: 8px 10px; + border: 1px solid var(--select-color); + box-shadow: 0 0 0 3px var(--select-ring); +} +select.share-modal-mode, +select.share-modal-row-mode { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: auto; + padding: 8px 26px 8px 10px; + border: 1px solid var(--dashboard-border); + border-radius: 8px; + background-color: var(--dashboard-card-background); + /* Chevron stroke: slate-500 clears the 3:1 non-text contrast minimum + on light surfaces; the dark override below lightens it again. */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + font-size: 13px; + font-family: inherit; + color: var(--dashboard-text-primary); + cursor: pointer; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s; +} +select.share-modal-mode { + flex-shrink: 0; +} +select.share-modal-mode:focus, +select.share-modal-row-mode:focus { + padding: 8px 26px 8px 10px; + border: 1px solid var(--select-color); + box-shadow: 0 0 0 3px var(--select-ring); +} +select.share-modal-mode:disabled, +select.share-modal-row-mode:disabled { + opacity: 0.55; + cursor: default; +} + +/* to prevent auto-zoom on input focus in mobile — .device-phone's global + 17px rule loses to these selectors, so restate the threshold */ +.device-phone input.share-modal-recipient, +.device-phone input.share-modal-recipient:focus, +.device-phone select.share-modal-mode, +.device-phone select.share-modal-row-mode { + font-size: 16px; +} + +.share-modal-submit { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 10px; + padding: 0 16px; + height: 38px; + border: 1px solid var(--dashboard-link); + border-radius: 10px; + background: var(--dashboard-link); + font-size: 13.5px; + font-weight: 600; + font-family: inherit; + color: #fff; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +@media (hover: hover) { + .share-modal-submit:hover:not(:disabled) { + background: var(--dashboard-link-hover); + border-color: var(--dashboard-link-hover); + } +} +.share-modal-submit:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} +.share-modal-submit:disabled { + background: var(--dashboard-input-background); + border-color: var(--dashboard-border); + color: var(--dashboard-text-muted); + cursor: default; +} +/* In flight it is disabled too, but should read as working, not empty */ +.share-modal-submit.share-modal-btn-busy:disabled { + background: var(--dashboard-link); + border-color: var(--dashboard-link); + color: #fff; + opacity: 0.75; +} + +/* Spinner: hidden until its owner is loading/busy */ +.share-modal-spinner { + display: none; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: share-modal-spin 0.7s linear infinite; +} +@keyframes share-modal-spin { + to { transform: rotate(360deg); } +} +.share-modal-loading { + display: flex; + justify-content: center; + padding: 18px 0; + color: var(--dashboard-text-muted); +} +.share-modal-loading .share-modal-spinner { + display: block; + width: 18px; + height: 18px; +} +.share-modal-btn-busy .share-modal-spinner { + display: block; +} + +/* Status line (aria-live). It collapses when empty rather than going + display:none — a live region revealed in the same frame as its text is + unreliably announced, so it stays in the a11y tree between messages. */ +.share-modal-status { + margin-top: 12px; + padding: 9px 12px; + border: 1px solid transparent; + border-radius: 8px; + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} +.share-modal-status:empty { + margin-top: 0; + padding: 0; + border-width: 0; +} +.share-modal-status-error { + background: var(--dashboard-danger-background); + border-color: var(--dashboard-danger-border); + color: var(--dashboard-danger-text); +} +.share-modal-status-success { + background: var(--dashboard-success-background); + border-color: var(--dashboard-success-border); + color: var(--dashboard-success-text); +} + +/* Access list */ +.share-modal-heading { + margin: 18px 0 2px; + font-size: 13px; + font-weight: 600; + color: var(--dashboard-text-secondary); +} +.share-modal-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 0; + border-bottom: 1px solid var(--dashboard-border); +} +.share-modal-row:last-child { + border-bottom: none; +} +.share-modal-avatar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + font-size: 13px; + font-weight: 600; + background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14); + color: hsl(var(--share-avatar-hue, 213), 45%, 38%); +} +.share-modal-row-who { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; +} +.share-modal-row-name { + font-size: 13.5px; + color: var(--dashboard-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.share-modal-row-via { + font-size: 12px; + color: var(--dashboard-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* Owner badge and the fixed mode of an inherited grant */ +.share-modal-row-tag { + flex-shrink: 0; + font-size: 12.5px; + color: var(--dashboard-text-secondary); +} +select.share-modal-row-mode { + flex-shrink: 0; + padding: 5px 24px 5px 8px; + border-color: transparent; + background-color: transparent; + font-size: 12.5px; + color: var(--dashboard-text-secondary); + background-position: right 6px center; +} +@media (hover: hover) { + select.share-modal-row-mode:hover:not(:disabled) { + background-color: var(--dashboard-hover); + color: var(--dashboard-text-primary); + } +} +select.share-modal-row-mode:focus { + padding: 5px 24px 5px 8px; + border: 1px solid var(--select-color); +} +.share-modal-revoke { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dashboard-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +@media (hover: hover) { + .share-modal-revoke:hover:not(:disabled) { + background: var(--dashboard-danger-background); + color: var(--dashboard-danger-text); + } +} +.share-modal-revoke:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} +.share-modal-empty { + margin: 0; + padding: 10px 0; + font-size: 13px; + color: var(--dashboard-text-muted); +} + +/* Inline revoke confirmation (in place of the row it removes) */ +.share-modal-row-confirm { + flex-wrap: wrap; +} +.share-modal-confirm-text { + flex: 1 1 180px; + min-width: 0; + font-size: 13px; + line-height: 1.45; + color: var(--dashboard-text-primary); + word-break: break-word; +} +.share-modal-confirm-actions { + flex-shrink: 0; + display: flex; + gap: 6px; + margin-left: auto; +} +.share-modal-btn-quiet, +.share-modal-btn-danger { + padding: 6px 12px; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.share-modal-btn-quiet { + border: 1px solid var(--dashboard-border); + background: var(--dashboard-card-background); + color: var(--dashboard-text-primary); +} +@media (hover: hover) { + .share-modal-btn-quiet:hover:not(:disabled) { + background: var(--dashboard-sidebar-background); + } +} +.share-modal-btn-danger { + border: 1px solid var(--dashboard-danger-border); + background: var(--dashboard-danger-background); + color: var(--dashboard-danger-text); +} +@media (hover: hover) { + .share-modal-btn-danger:hover:not(:disabled) { + border-color: var(--dashboard-danger-text); + } +} +.share-modal-btn-quiet:focus-visible, +.share-modal-btn-danger:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} +.share-modal-btn-quiet:disabled, +.share-modal-btn-danger:disabled { + opacity: 0.55; + cursor: default; +} + +/* Touch: every control clears 44px. The icon buttons keep their 30-32px + visuals but hit-test at 44x44 via a centered pseudo-element (the 10px + gap to the row's select and the 18px between rows absorb the overhang + without overlap); everything else grows for real via min-height, which + holds whatever the inherited line-height turns out to be. + This block sits below every base rule it overrides — a media query + adds no specificity, so source order decides. */ +@media (pointer: coarse) { + .share-modal-close, + .share-modal-revoke { + position: relative; + } + .share-modal-close::before, + .share-modal-revoke::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 44px; + height: 44px; + transform: translate(-50%, -50%); + } + input.share-modal-recipient, + input.share-modal-recipient:focus, + select.share-modal-mode, + select.share-modal-mode:focus { + min-height: 44px; + } + select.share-modal-row-mode, + select.share-modal-row-mode:focus { + min-height: 44px; + padding-top: 9px; + padding-bottom: 9px; + } + .share-modal-submit { + height: 44px; + } + .share-modal-btn-quiet, + .share-modal-btn-danger { + box-sizing: border-box; + min-height: 44px; + padding: 10px 16px; + } +} + +@media (prefers-color-scheme: dark) { + /* The avatar's tinted fill needs a lighter glyph over dark surfaces */ + .share-modal-avatar { + background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2); + color: hsl(var(--share-avatar-hue, 213), 55%, 72%); + } + /* Chevron: slate-400, lighter than the light theme's stroke */ + select.share-modal-mode, + select.share-modal-row-mode { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + } +} + +/* Mobile: dock to the bottom as a slide-up sheet */ +@media (max-width: 600px) { + .share-modal-overlay { + align-items: flex-end; + padding: 0; + } + .share-modal { + width: 100%; + max-width: none; + max-height: 88vh; + border-radius: 16px 16px 0 0; + border-bottom: none; + transform: translateY(24px); + } + .share-modal-overlay.share-modal-show .share-modal { + transform: translateY(0); + } + /* The sheet sits flush with the physical bottom edge (viewport-fit= + cover), so the scrollable body clears the home indicator itself. */ + .share-modal-body { + padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)); + } +} + +@media (prefers-reduced-motion: reduce) { + .share-modal-overlay, + .share-modal { + transition: none; + } + .share-modal-spinner { + animation-duration: 1.6s; + } +} + /* A tile installed by a deep-link landing, not yet arrived: rendered in place — layout, page count, and the wayfinding flip all see it — but held invisible for the intro's arrival beat to reveal (see _spliceDeepLinkApp / diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js index 19edbb889..1cb56dc15 100644 --- a/src/gui/src/helpers/generate_file_context_menu.js +++ b/src/gui/src/helpers/generate_file_context_menu.js @@ -44,6 +44,7 @@ import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } * @param {string} options.associated_app_name - Optional associated app * @param {Function} options.onOpen - Optional custom open handler (used by Dashboard) * @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element} + * @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element} * @returns {Promise} Array of context menu items */ const generate_file_context_menu = async function (options) { @@ -323,6 +324,18 @@ const generate_file_context_menu = async function (options) { menu_items.push({ html: i18n('share_ellipsis'), onClick: async function () { + // The Dashboard swaps in its own responsive modal via this hook; + // everywhere else falls back to the desktop share window. + if ( options.onShare ) { + options.onShare({ + name: $(el_item).attr('data-name'), + path: $(el_item).attr('data-path'), + uid: $(el_item).attr('data-uid'), + element: el_item, + }); + return; + } + UIWindowShare({ path: $(el_item).attr('data-path'), name: $(el_item).attr('data-name'), diff --git a/src/gui/src/helpers/share_modes.js b/src/gui/src/helpers/share_modes.js new file mode 100644 index 000000000..5261a9cd3 --- /dev/null +++ b/src/gui/src/helpers/share_modes.js @@ -0,0 +1,58 @@ +/* + * 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 . + */ + +// Access levels the sharing dialogs offer, shared so the two can't drift. + +// The API also accepts `see` and `list`, a developer-level distinction with no +// place in these dialogs; a row already set to one is shown as-is. +export const MODES = ['read', 'write', 'manage']; + +/** + * Human-readable label for an access mode, ready to drop into HTML. + * + * Already HTML-safe — encoding it again renders the `&` in "Can edit & share". + * + * @param {string} mode + * @returns {string} HTML-safe label + */ +export const mode_label = (mode) => { + if ( mode === 'write' ) return i18n('share_access_write'); + if ( mode === 'manage' ) return i18n('share_access_manage'); + if ( mode === 'read' ) return i18n('share_access_read'); + return html_encode(mode); +}; + +/** + * `