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); +}; + +/** + * `