diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index 453d7528b..98d335aa6 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -24,14 +24,15 @@ import { invalidate_shared_roots } from '../../helpers/sharedAccess.js'; import { icons } from '../../helpers/actionIcons.js'; import { mode_label, options_for } from '../../helpers/shareModes.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; -import { avatarHue, avatarInitial } from './shareAvatar.js'; +import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js'; import { has_direct_share, mark_item_shared, } from '../../helpers/sharedBadge.js'; import { share_outcome } from '../../helpers/shareOutcome.js'; import { aggregateOwners, aggregateShares, linkShareState, missingPathsFor } from './shareAggregate.js'; -import { team_label, teams_for_sharing } from '../../helpers/shareTeams.js'; +import shareRecipientPicker from '../../helpers/shareRecipientPicker.js'; +import { teams_for_sharing } from '../../helpers/shareTeams.js'; import { share_link_for } from '../../helpers/sharePaths.js'; import { is_plan_gate_error, open_upgrade_flow } from '../../helpers/planGate.js'; import { with_verification_gate } from '../../helpers/verification_gates.js'; @@ -134,7 +135,6 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // Nothing to share: an empty selection is a caller's mistake, not a dialog. if ( total === 0 ) return { close: () => {} }; const items_list_id = `share-modal-items-${++modal_seq}`; - const teams_select_id = `share-modal-teams-${modal_seq}`; // The header names the one item, or the size of the pile with the names // folded into an expandable list below it. @@ -182,7 +182,6 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse ${i18n('share')} - ${allow_manage ? `

${i18n('share_general_access')}

@@ -243,6 +242,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse closed = true; $overlay.removeClass('share-modal-show'); $(document).off('keydown.share-modal'); + // The recipient field listens on the document to know when a click + // landed outside its suggestions; that has to come off with the dialog. + picker.destroy(); setTimeout(() => $overlay.remove(), 200); if ( el_previous_focus && document.contains(el_previous_focus) ) { try { @@ -631,76 +633,56 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse /** A team is named by uid; a person by the name the row already shows. */ const recipient_of = (group) => (group.teamUid ? { team: group.teamUid } : group.name); - // One grant reaching every colleague. Its own control, as on the desktop - // dialog: a bare string in the field above already reads as a person. - let teams = []; - (async () => { - teams = await teams_for_sharing(); - if ( closed || teams.length === 0 ) return; - const options = teams - .map((team) => ``) - .join(''); - $overlay.find('.share-modal-teams').html(` - - - - - `).prop('hidden', false); - })(); - - $overlay.on('click', '.share-modal-team-btn', async function () { - const team = teams.find((t) => t.uid === $overlay.find('.share-modal-team-select').val()); - if ( ! team ) return; - const name = team_label(team); - const $btn = $(this).prop('disabled', true); - try { - const created = await grant_access( - { team: team.uid }, - $overlay.find('.share-modal-team-mode').val(), - target_paths, - ); - const granted = created?.length ?? 0; - show_success(granted < total - ? i18n('share_shared_with_partial', { recipient: name, count: granted, total }) - : shared_message(name, total)); - invalidate_shared_roots(); - await refresh(); - } catch (err) { - show_error(error_html(err)); - } - $btn.prop('disabled', false); - focus_dialog(); + // One field for every kind of recipient: a colleague, a whole team, someone + // shared with before, or an address typed from scratch. + const picker = shareRecipientPicker({ + $input: $recipient, + $row: $overlay.find('.share-modal-add-row'), + // Nobody already on the list below: offering them again would only + // re-grant what their row already shows. + excluded: () => last_groups.map((group) => group.key), + onChange: () => { + $submit.prop('disabled', ! picker.recipient()); + // Choosing again retires a stale success/error message. + clear_status(); + }, }); + (async () => { + const teams = await teams_for_sharing(); + if ( closed ) return; + picker.setTeams(teams); + if ( teams.length ) { + // Not the encoded form: an attribute set from JS shows entities + // literally. + const label = i18n('share_add_people_teams', [], false); + $recipient.attr('placeholder', label).attr('aria-label', label); + } + })(); + /** "Shared with ann" / "Shared with ann on 4 items". */ const shared_message = (recipient, count) => (count === 1 ? i18n('share_shared_with', { recipient }) : i18n('share_shared_with_items', { recipient, count })); - $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; + // A team is named by uid; anything typed goes as-is, since a bare + // string is what the backend reads as an email or a username. + const chosen = picker.recipient(); + if ( ! chosen ) return; + const recipient = chosen.label; $submit.prop('disabled', true).addClass('share-modal-btn-busy'); try { const created = await grant_access( - recipient, + chosen.value, $overlay.find('.share-modal-mode').val(), target_paths, ); // 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() === ''); + if ( $recipient.val().trim() === recipient ) picker.clear(); + $submit.prop('disabled', ! picker.recipient()); // A pair the backend refused doesn't fail the others, so say how // many items actually landed rather than implying all of them did. const granted = created?.length ?? 0; @@ -724,6 +706,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse ? i18n('share_invited_items', { recipient, count: total }) : shared_message(recipient, total)); } + picker.remember(chosen, created); invalidate_shared_roots(); await refresh(); $recipient.get(0)?.focus({ preventScroll: true }); diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js index ab65908de..43f5a24e4 100644 --- a/src/gui/src/UI/UIWindowShare.js +++ b/src/gui/src/UI/UIWindowShare.js @@ -26,6 +26,8 @@ import { icons } from '../helpers/actionIcons.js'; import { mode_label, options_for } from '../helpers/shareModes.js'; import { has_direct_share, mark_item_shared } from '../helpers/sharedBadge.js'; import { share_outcome } from '../helpers/shareOutcome.js'; +import shareRecipientPicker from '../helpers/shareRecipientPicker.js'; +import { share_key } from '../helpers/shareSuggest.js'; import { team_for_share, team_label, teams_for_sharing, } from '../helpers/shareTeams.js'; @@ -69,23 +71,11 @@ async function UIWindowShare (options) { h += ``; h += ''; h += ``; - // A team can't be typed into the field above: a bare string there is - // already read as an email or a username, so it needs its own control. - h += ''; - // The owner's switch between people-only and anyone with the link. A // delegate passes access on to people; opening the item to everyone is // the owner's call, so nobody else gets the control. @@ -141,6 +131,9 @@ async function UIWindowShare (options) { onAppend: function (this_window) { $(this_window).find('.share-recipient').get(0)?.focus({ preventScroll: true }); }, + // The recipient field listens on the document to know when a click + // landed outside its suggestions; that has to come off with the window. + on_close: () => picker.destroy(), window_class: 'window-share', window_css: { height: 'initial' }, body_css: { width: 'initial', padding: '0', 'background-color': 'rgb(245 247 249)' }, @@ -175,14 +168,28 @@ async function UIWindowShare (options) { /** The item's uid, which its link is built on; looked up once. */ let item_uid = options.uid ?? null; - const render_team_picker = () => { - if ( ! teams.length ) return; - const options = teams - .map(team => ``) - .join(''); - $(el_window).find('.share-team-select').html(options); - $(el_window).find('.share-team').show(); - }; + const $recipient = $(el_window).find('.share-recipient'); + const $share_btn = $(el_window).find('.share-btn'); + + // One field for every kind of recipient: a colleague, a whole team, someone + // shared with before, or an address typed from scratch. + const picker = shareRecipientPicker({ + $input: $recipient, + $row: $recipient.closest('.share-dialog-row'), + // Nobody already on the list below: offering them again would only + // re-grant what the row next to it already shows. + excluded: () => shown_shares.map(share_key).filter(Boolean), + onChange: () => { $share_btn.prop('disabled', ! picker.recipient()); }, + }); + $share_btn.prop('disabled', true); + + // Enter shares, the way it submits the Dashboard's dialog. Bound after the + // picker, which takes the press for itself while it is choosing a row. + $recipient.on('keydown', (e) => { + if ( e.key !== 'Enter' || e.isDefaultPrevented() ) return; + if ( $share_btn.prop('disabled') ) return; + $share_btn.trigger('click'); + }); const render = (shares) => { shown_shares = Array.isArray(shares) ? shares : []; @@ -330,60 +337,34 @@ async function UIWindowShare (options) { }); $(el_window).on('click', '.share-btn', async function () { - const recipient = $(el_window).find('.share-recipient').val().trim(); - if ( !recipient ) return; + // A team is named by uid; anything typed goes as-is, since a bare + // string is what the backend reads as an email or a username. + const chosen = picker.recipient(); + if ( ! chosen ) return; $(this).prop('disabled', true); try { const created = await with_verification_gate(() => puter.fs.share({ path: item_path, - recipient, + recipient: chosen.value, mode: $(el_window).find('.share-mode').val(), })); - $(el_window).find('.share-recipient').val(''); + picker.clear(); $error.hide(); // `i18n()` encodes its replacements; encoding first would show the // entities to anyone whose address or username contains one. show_success( i18n(SHARE_MESSAGE[share_outcome(created, shown_shares)], { - recipient, + recipient: chosen.label, }), ); + picker.remember(chosen, created); invalidate_shared_roots(); await refresh(); } catch (e) { show_error(e?.message ?? i18n('share_failed')); } finally { - $(this).prop('disabled', false); - } - }); - - $(el_window).on('click', '.share-team-btn', async function () { - const uid = $(el_window).find('.share-team-select').val(); - const team = teams.find(t => t.uid === uid); - if ( ! team ) return; - - $(this).prop('disabled', true); - try { - // The object form, not a string: a `team:`-style prefix would - // change how an already-released spelling is read. - const created = await with_verification_gate(() => puter.fs.share({ - path: item_path, - recipient: { team: team.uid }, - mode: $(el_window).find('.share-team-mode').val(), - })); - $error.hide(); - show_success( - i18n(SHARE_MESSAGE[share_outcome(created, shown_shares)], { - recipient: team_label(team), - }), - ); - invalidate_shared_roots(); - await refresh(); - } catch (e) { - show_error(e?.message ?? i18n('share_failed')); - } finally { - $(this).prop('disabled', false); + $(this).prop('disabled', ! picker.recipient()); } }); @@ -457,9 +438,12 @@ async function UIWindowShare (options) { }).catch(() => { /* the button just stays hidden */ }); } - // Teams first: the access list names its rows from them. + // Teams first: the access list names its rows from them, and the recipient + // field suggests both them and the people in them. teams = await teams_for_sharing(); - render_team_picker(); + picker.setTeams(teams); + // Not the encoded form: an attribute set from JS shows entities literally. + if ( teams.length ) $recipient.attr('placeholder', i18n('share_add_people_teams', [], false)); await refresh(); return el_window; } diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 1b5e18b48..b723a62cb 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -2531,8 +2531,6 @@ input.share-modal-recipient:focus { box-shadow: 0 0 0 3px var(--select-ring); } select.share-modal-mode, -select.share-modal-team-select, -select.share-modal-team-mode, select.share-modal-link-access, select.share-modal-link-mode, select.share-modal-row-mode { @@ -2557,13 +2555,10 @@ select.share-modal-row-mode { transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s; } select.share-modal-mode, -select.share-modal-team-mode, select.share-modal-link-mode { flex-shrink: 0; } select.share-modal-mode:focus, -select.share-modal-team-select:focus, -select.share-modal-team-mode:focus, select.share-modal-link-access:focus, select.share-modal-link-mode:focus, select.share-modal-row-mode:focus { @@ -2572,8 +2567,6 @@ select.share-modal-row-mode:focus { box-shadow: 0 0 0 3px var(--select-ring); } select.share-modal-mode:disabled, -select.share-modal-team-select:disabled, -select.share-modal-team-mode:disabled, select.share-modal-link-access:disabled, select.share-modal-link-mode:disabled, select.share-modal-row-mode:disabled { @@ -2586,69 +2579,23 @@ select.share-modal-row-mode:disabled { .device-phone input.share-modal-recipient, .device-phone input.share-modal-recipient:focus, .device-phone select.share-modal-mode, -.device-phone select.share-modal-team-select, -.device-phone select.share-modal-team-mode, .device-phone select.share-modal-link-access, .device-phone select.share-modal-link-mode, .device-phone select.share-modal-row-mode { font-size: 16px; } -/* Its own block under the people field, separated the way the desktop - dialog separates them. The button is quieter: an alternative, not the - main path. */ -.share-modal-teams { - margin-top: 18px; - padding-top: 16px; - border-top: 1px solid var(--dashboard-border); -} - -.share-modal-teams-label { - display: block; - margin-bottom: 8px; - font-size: 13px; - font-weight: 600; - color: var(--dashboard-text-primary); -} - -.share-modal-teams-note { - margin: 8px 0 0; - font-size: 12.5px; - line-height: 1.45; - color: var(--dashboard-text-hint); -} - -.share-modal-team-select { - min-width: 0; - flex: 1 1 auto; -} - -.share-modal-team-btn { - width: 100%; - height: 38px; - margin-top: 10px; - padding: 0 16px; - border: 1px solid var(--dashboard-border); - border-radius: 10px; - background: transparent; - font-size: 13.5px; - font-weight: 600; - font-family: inherit; - color: var(--dashboard-text-primary); - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -@media (hover: hover) { - .share-modal-team-btn:hover:not(:disabled) { - background: var(--dashboard-card-background); - border-color: var(--dashboard-text-hint); - } -} - -.share-modal-team-btn:disabled { - opacity: 0.6; - cursor: default; +/* Recipient suggestions. The structure lives in style.css, shared with the + desktop share dialog; only the palette is restated here so the list picks + up the dashboard's tokens in both themes. */ +.share-modal { + --share-suggest-bg: var(--dashboard-card-background); + --share-suggest-border: var(--dashboard-border); + --share-suggest-hover: var(--dashboard-hover); + --share-suggest-shadow: var(--dashboard-shadow-medium); + --share-suggest-fg: var(--dashboard-text-primary); + --share-suggest-muted: var(--dashboard-text-muted); + --share-suggest-picked-bg: var(--select-tint); } /* General access: the owner's switch between people-only and anyone with @@ -3070,10 +3017,17 @@ select.share-modal-row-mode:focus { @media (prefers-color-scheme: dark) { /* The avatar's tinted fill needs a lighter glyph over dark surfaces */ - .share-modal-avatar { + .share-modal-avatar, + .share-modal .share-suggest-avatar { background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2); color: hsl(var(--share-avatar-hue, 213), 55%, 72%); } + /* Restated after the rule above, which would otherwise repaint the glyph + variant with a hue it does not carry. */ + .share-modal .share-suggest-avatar-team { + background: var(--share-suggest-hover); + color: var(--share-suggest-muted); + } /* Chevron: slate-400, lighter than the light theme's stroke */ select.share-modal-mode, select.share-modal-row-mode { diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index e99767b3f..a5fbcb6da 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -6623,6 +6623,208 @@ html.dark-mode .usage-table-show-less:hover { flex: none; } +/* -- Recipient suggestions -- + Shared by this dialog and the Dashboard's share modal; each host sets the + palette below through its own tokens (dashboard.css restates them for + .share-modal). The list is anchored to the recipient row and floats over + what follows it, so opening and closing it never resizes the dialog. */ +.share-dialog { + --share-suggest-bg: #fff; + --share-suggest-border: #dfe3e8; + --share-suggest-hover: #eef1f4; + --share-suggest-shadow: rgba(15, 23, 42, 0.12); + --share-suggest-fg: #3e5362; + --share-suggest-muted: #7f8b99; + --share-suggest-picked-bg: #eef4fb; +} + +.share-suggest-field { + position: relative; + display: flex; + align-items: center; + flex: 1 1 0%; + min-width: 0; +} + +/* The field locks once an offer is chosen: a team is shared with by uid, so + its name left editable would go out as a username. */ +.share-suggest-picked .share-suggest-input, +.share-suggest-picked .share-suggest-input:focus { + padding-right: 30px; + background-color: var(--share-suggest-picked-bg, #eef4fb); + cursor: default; +} + +.share-suggest-clear { + position: absolute; + right: 6px; + top: 50%; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + transform: translateY(-50%); + border: none; + border-radius: 50%; + background: transparent; + color: var(--share-suggest-muted, #7f8b99); + cursor: pointer; +} + +@media (hover: hover) { + .share-suggest-clear:hover { + background: var(--share-suggest-hover, #eef1f4); + color: var(--share-suggest-fg, #3e5362); + } +} + +.share-suggest-clear:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} + +/* The row the list hangs off. The row keeps its own margins, so the list + below it neither inherits nor disturbs the dialog's spacing. */ +.share-suggest-anchor { + position: relative; +} + +.share-suggest { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 20; + margin: 0; + border: 1px solid var(--share-suggest-border, #dfe3e8); + border-radius: 10px; + background: var(--share-suggest-bg, #fff); + box-shadow: 0 8px 26px var(--share-suggest-shadow, rgba(15, 23, 42, 0.16)); + overflow: hidden; +} + +/* Opened upwards where there isn't room below it — `place()` decides, and + the clipping ancestor it measures against is what would cut the list off. */ +.share-suggest.share-suggest-above { + top: auto; + bottom: calc(100% + 6px); +} + +.share-suggest[hidden], +.share-suggest-note[hidden], +.share-suggest-clear[hidden] { + display: none; +} + +/* `place()` narrows this to the room actually available; the value here is + the ceiling it clamps to. */ +.share-suggest-list { + margin: 0; + padding: 4px; + max-height: 232px; + overflow-y: auto; + list-style: none; +} + +.share-suggest-option { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 9px; + border-radius: 7px; + cursor: pointer; +} + +@media (hover: hover) { + .share-suggest-option:hover { + background: var(--share-suggest-hover, #eef1f4); + } +} + +/* Keyboard highlight. Not :focus — focus stays in the field, as the combobox + pattern requires, and aria-activedescendant carries it to the reader. */ +.share-suggest-option.share-suggest-active { + background: var(--share-suggest-hover, #eef1f4); +} + +.share-suggest-avatar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 12.5px; + font-weight: 600; + background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14); + color: hsl(var(--share-avatar-hue, 213), 45%, 38%); +} + +/* A team is not a person, so it gets a glyph rather than an initial. */ +.share-suggest-avatar-team { + background: var(--share-suggest-hover, #eef1f4); + color: var(--share-suggest-muted, #7f8b99); +} + +.share-suggest-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.share-suggest-name, +.share-suggest-sub { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.share-suggest-name { + font-size: 13.5px; + color: var(--share-suggest-fg, #3e5362); +} + +.share-suggest-sub { + font-size: 12px; + color: var(--share-suggest-muted, #7f8b99); +} + +.share-suggest-loading { + padding: 10px 9px; + font-size: 13px; + color: var(--share-suggest-muted, #7f8b99); +} + +/* What sharing with a whole team means; shown only while one is chosen. */ +.share-suggest-note { + margin: 8px 0 12px; + font-size: 12px; + line-height: 1.45; + color: var(--share-suggest-muted, #7f8b99); +} + +/* Touch: rows grow for real, the clear button keeps its 22px visual and + hit-tests at 44x44 through a centered pseudo-element — the same trick the + share modal's icon buttons use. */ +@media (pointer: coarse) { + .share-suggest-option { + min-height: 44px; + } + .share-suggest-clear::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 44px; + height: 44px; + transform: translate(-50%, -50%); + } +} + .share-dialog-heading { font-size: 13px; font-weight: 500; @@ -6632,27 +6834,6 @@ html.dark-mode .usage-table-show-less:hover { letter-spacing: 0.04em; } -/* Only rendered for a user who has a team; hidden for everyone else. */ -.share-team { - margin-top: 18px; -} - -.share-dialog .share-team-select { - flex: 1; - min-width: 0; -} - -.share-dialog .share-team-mode { - width: 130px; - flex: none; -} - -.share-team-note { - font-size: 12px; - color: #7f8b99; - margin: -8px 0 12px; -} - /* General access: the owner's switch between people-only and anyone with the link. */ .share-general-access { diff --git a/src/gui/src/UI/Dashboard/shareAvatar.js b/src/gui/src/helpers/shareAvatar.js similarity index 100% rename from src/gui/src/UI/Dashboard/shareAvatar.js rename to src/gui/src/helpers/shareAvatar.js diff --git a/src/gui/src/UI/Dashboard/shareAvatar.test.js b/src/gui/src/helpers/shareAvatar.test.js similarity index 100% rename from src/gui/src/UI/Dashboard/shareAvatar.test.js rename to src/gui/src/helpers/shareAvatar.test.js diff --git a/src/gui/src/helpers/shareRecents.js b/src/gui/src/helpers/shareRecents.js new file mode 100644 index 000000000..e87e49717 --- /dev/null +++ b/src/gui/src/helpers/shareRecents.js @@ -0,0 +1,128 @@ +/* + * 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 . + */ + +// Who the user last shared with, so the recipient field can offer them again. +// Kept in the account's own key-value store rather than localStorage, so the +// list follows the user between browsers the way their other preferences do. + +/** + * A recipient worth offering again. `kind` matches the access list's row kinds + * so the two can be compared by key. + * + * @typedef {Object} RecentRecipient + * @property {'user'|'invite'|'team'} kind + * @property {string} id - Username, invited email address, or team uid + * @property {string} name - What to show; the id itself for a person + */ + +const KV_KEY = 'recent_share_recipients'; + +/** A shortcut, not an address book: enough to cover who you share with weekly. */ +export const MAX_RECENTS = 20; + +const KINDS = new Set(['user', 'invite', 'team']); + +/** + * The entries of `value` that are usable, capped. Anything else in the key — + * a shape from an older build, or a value written by hand — is dropped rather + * than rendered. + * + * @param {unknown} value + * @returns {RecentRecipient[]} + */ +export const sanitize_recents = (value) => { + if ( ! Array.isArray(value) ) return []; + const out = []; + const seen = new Set(); + for ( const entry of value ) { + if ( ! KINDS.has(entry?.kind) ) continue; + const id = typeof entry.id === 'string' ? entry.id.trim() : ''; + if ( id === '' ) continue; + const key = `${entry.kind}:${id.toLowerCase()}`; + if ( seen.has(key) ) continue; + seen.add(key); + out.push({ + kind: entry.kind, + id, + name: typeof entry.name === 'string' && entry.name.trim() !== '' + ? entry.name + : id, + }); + if ( out.length === MAX_RECENTS ) break; + } + return out; +}; + +/** + * `list` with `entry` moved (never duplicated) to the front, capped. + * + * @param {RecentRecipient[]} list + * @param {RecentRecipient} entry + * @returns {RecentRecipient[]} + */ +export const merge_recent = (list, entry) => { + const clean = sanitize_recents([entry]); + if ( clean.length === 0 ) return sanitize_recents(list); + const key = `${clean[0].kind}:${clean[0].id.toLowerCase()}`; + const rest = sanitize_recents(list).filter( + (item) => `${item.kind}:${item.id.toLowerCase()}` !== key, + ); + return [clean[0], ...rest].slice(0, MAX_RECENTS); +}; + +/** Read once per session; every dialog after the first opens on what's here. */ +let cached = null; + +/** + * The recipients this account last shared with, most recent first. Never + * rejects: a store that is unreachable or empty means "suggest nothing". + * + * @returns {Promise} + */ +export const recent_recipients = () => { + cached ??= (async () => { + try { + return sanitize_recents(await puter.kv.get(KV_KEY)); + } catch { + return []; + } + })(); + return cached; +}; + +/** + * Record a successful share so the recipient comes up first next time. + * Best-effort: a store that refuses the write costs the user nothing. + * + * @param {RecentRecipient} entry + * @returns {Promise} the list as it now stands + */ +export const remember_recipient = async (entry) => { + const next = merge_recent(await recent_recipients(), entry); + cached = Promise.resolve(next); + try { + await puter.kv.set(KV_KEY, next); + } catch { /* the in-memory list still serves this session */ } + return next; +}; + +/** Drops the session cache. For tests, and for a change of signed-in user. */ +export const forget_recent_recipients = () => { + cached = null; +}; diff --git a/src/gui/src/helpers/shareRecents.test.js b/src/gui/src/helpers/shareRecents.test.js new file mode 100644 index 000000000..629f5f068 --- /dev/null +++ b/src/gui/src/helpers/shareRecents.test.js @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MAX_RECENTS, + forget_recent_recipients, + merge_recent, + recent_recipients, + remember_recipient, + sanitize_recents, +} from './shareRecents.js'; + +const ANN = { kind: 'invite', id: 'ann@example.com', name: 'ann@example.com' }; +const BOB = { kind: 'user', id: 'bob', name: 'bob' }; + +const original_puter = globalThis.puter; + +beforeEach(() => { + forget_recent_recipients(); +}); + +afterEach(() => { + globalThis.puter = original_puter; +}); + +describe('sanitize_recents', () => { + it('drops anything that is not a usable recipient', () => { + expect(sanitize_recents([ + BOB, + { kind: 'nonsense', id: 'x', name: 'x' }, + { kind: 'user', id: ' ' }, + null, + 'bob', + ])).toEqual([BOB]); + }); + + it('falls back to the id when there is no name to show', () => { + expect(sanitize_recents([{ kind: 'user', id: 'bob' }])) + .toEqual([{ kind: 'user', id: 'bob', name: 'bob' }]); + }); + + it('keeps one entry per recipient, the first', () => { + expect(sanitize_recents([ + { kind: 'user', id: 'bob', name: 'bob' }, + { kind: 'user', id: 'BOB', name: 'other' }, + ])).toEqual([BOB]); + }); + + it('answers an unusable stored value with nothing', () => { + expect(sanitize_recents(undefined)).toEqual([]); + expect(sanitize_recents('bob')).toEqual([]); + }); +}); + +describe('merge_recent', () => { + it('moves a repeat recipient to the front rather than duplicating them', () => { + expect(merge_recent([ANN, BOB], BOB)).toEqual([BOB, ANN]); + }); + + it('caps the list so it stays a shortcut', () => { + const many = Array.from({ length: MAX_RECENTS }, (_, i) => ({ + kind: 'user', id: `u${i}`, name: `u${i}`, + })); + const out = merge_recent(many, BOB); + expect(out).toHaveLength(MAX_RECENTS); + expect(out[0]).toEqual(BOB); + expect(out).not.toContainEqual({ kind: 'user', id: 'u19', name: 'u19' }); + }); + + it('leaves the list alone when the entry is unusable', () => { + expect(merge_recent([BOB], { kind: 'user' })).toEqual([BOB]); + }); +}); + +describe('recent_recipients', () => { + it('reads the store once however many dialogs ask', async () => { + const get = vi.fn(async () => [BOB]); + globalThis.puter = { kv: { get, set: vi.fn() } }; + + await expect(recent_recipients()).resolves.toEqual([BOB]); + await expect(recent_recipients()).resolves.toEqual([BOB]); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('suggests nothing when the store is unreachable', async () => { + globalThis.puter = { kv: { get: vi.fn(async () => { throw new Error('offline'); }) } }; + await expect(recent_recipients()).resolves.toEqual([]); + }); +}); + +describe('remember_recipient', () => { + it('writes the recipient to the front of the stored list', async () => { + const set = vi.fn(async () => true); + globalThis.puter = { kv: { get: vi.fn(async () => [ANN]), set } }; + + await expect(remember_recipient(BOB)).resolves.toEqual([BOB, ANN]); + expect(set).toHaveBeenCalledWith('recent_share_recipients', [BOB, ANN]); + // Served from memory afterwards, without reading back. + await expect(recent_recipients()).resolves.toEqual([BOB, ANN]); + }); + + it('still serves the session when the write is refused', async () => { + globalThis.puter = { + kv: { + get: vi.fn(async () => []), + set: vi.fn(async () => { throw new Error('quota'); }), + }, + }; + await expect(remember_recipient(BOB)).resolves.toEqual([BOB]); + await expect(recent_recipients()).resolves.toEqual([BOB]); + }); +}); diff --git a/src/gui/src/helpers/shareRecipientPicker.js b/src/gui/src/helpers/shareRecipientPicker.js new file mode 100644 index 000000000..19585b6d9 --- /dev/null +++ b/src/gui/src/helpers/shareRecipientPicker.js @@ -0,0 +1,505 @@ +/* + * 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 { avatarHue, avatarInitial } from './shareAvatar.js'; +import { colleagues_for_sharing } from './shareTeams.js'; +import { recent_recipients, remember_recipient } from './shareRecents.js'; +import { build_suggestions, recipient_for } from './shareSuggest.js'; + +// Turns the sharing dialogs' recipient field into one control for every kind of +// recipient: a colleague, a whole team, someone shared with before, or an +// address typed from scratch. Shared by both dialogs so the two can't drift; +// each styles the `share-suggest-*` classes through its own tokens. + +const teamIcon = ``; +const clearIcon = ``; + +// Distinguishes one open dialog's listbox from another's, since the field +// points at it by id. +let picker_seq = 0; + +/** + * What the field currently resolves to. + * + * @typedef {Object} ChosenRecipient + * @property {string|{ team: string }} value - What to hand `puter.fs.share` + * @property {string} label - What to call them on screen + * @property {import('./shareSuggest.js').Suggestion|null} picked - The offer + * that was chosen, or null when the address was typed + */ + +/** + * Wires a suggesting combobox onto an existing recipient ``. + * + * The field is locked once an offer is chosen — a team is shared with by uid, + * so its name left loose in an editable field would be sent as a username. Any + * printable key, Backspace or the clear button unlocks it again. + * + * @param {Object} opts + * @param {jQuery} opts.$input - The recipient field, already in the document + * @param {jQuery} opts.$row - The row holding it; the list opens below this + * @param {() => Iterable} [opts.excluded] - Keys already on the access + * list, which are not worth offering again + * @param {() => void} [opts.onChange] - Called whenever the field resolves to + * something different (for the Share button's enabled state) + * @returns {{ + * recipient: () => ChosenRecipient|null, + * setTeams: (teams: Array) => void, + * remember: (chosen: ChosenRecipient|null, created?: Array) => void, + * clear: () => void, + * close: () => void, + * destroy: () => void, + * }} + */ +export default function shareRecipientPicker ({ + $input, + $row, + excluded = () => [], + onChange = () => {}, +}) { + const list_id = `share-suggest-list-${++picker_seq}`; + + const $field = $(''); + // Both dialogs focus this field as they open, and an element that leaves + // the document — which is what moving it into the wrapper does — takes its + // focus with it. Put it back rather than leaving the caller to notice. + const had_focus = document.activeElement === $input.get(0); + $input.before($field); + $field.append($input); + if ( had_focus ) $input.get(0)?.focus({ preventScroll: true }); + const $clear = $(``); + $field.append($clear); + + const $panel = $(``); + const $note = $(``); + // The list floats over what is below it rather than pushing it down: a + // dialog that grows and shrinks under the cursor is worse than one that is + // briefly covered. It goes *inside* the row so the row is its containing + // block — as a sibling it would resolve against whatever is positioned + // further up — and being out of flow, the row's own layout is untouched. + $row.addClass('share-suggest-anchor').append($panel).after($note); + + // The class is what the locked-field styling hangs on: each dialog's own + // input rules match at a specificity a bare descendant selector loses to. + $input + .addClass('share-suggest-input') + .attr('role', 'combobox') + .attr('aria-expanded', 'false') + .attr('aria-controls', list_id) + .attr('aria-autocomplete', 'list'); + + /** @type {Array} */ let teams = []; + /** @type {Array} */ let members = []; + /** @type {Array} */ let recents = []; + /** @type {Promise|null} */ let loading = null; + let loaded = false; + // Bumped whenever the teams change, so a load started for the old set + // cannot land on top of the new one. + let load_gen = 0; + let destroyed = false; + + /** @type {import('./shareSuggest.js').Suggestion|null} */ let picked = null; + /** @type {import('./shareSuggest.js').Suggestion[]} */ let shown = []; + let active = -1; + let open = false; + + /** + * How long a load may take before the list admits it is loading. Long + * enough to cover an ordinary round trip, short enough that a slow one + * still says something. + */ + const WAIT_GRACE = 250; + /** @type {ReturnType|null} */ let wait_timer = null; + let waiting = false; + + const stop_waiting = () => { + if ( wait_timer !== null ) clearTimeout(wait_timer); + wait_timer = null; + waiting = false; + }; + + // -- Data -- + + /** Loaded on first use, not on open: a dialog nobody types in costs nothing. */ + const ensure_loaded = () => { + if ( loading ) return loading; + const gen = ++load_gen; + loading = Promise.all([ + colleagues_for_sharing(teams), + recent_recipients(), + ]).then(([colleagues, seen]) => { + if ( gen !== load_gen ) return; + members = colleagues; + recents = seen; + loaded = true; + }).catch(() => { + // Neither source rejects in practice; an empty list is the answer + // either way, and the field still takes a typed address. + if ( gen === load_gen ) loaded = true; + }); + return loading; + }; + + const current_suggestions = () => build_suggestions({ + query: String($input.val() ?? ''), + teams, + members, + recents, + exclude: excluded(), + self: window.user?.username ?? null, + }); + + // -- Placement -- + + // What the list needs beyond its own rows: the gap under the row, the + // panel's border and padding, and a little air at the clipping edge. + const CHROME = 24; + // How tall the list may grow before it scrolls, matching the CSS ceiling. + const MAX_HEIGHT = 232; + const MIN_HEIGHT = 120; + + /** + * The box the list has to stay inside: the viewport, narrowed to the + * nearest ancestor that clips — the modal's scrolling body in one dialog + * and the window's in the other. Both bound it, and a dialog dragged low + * on a short screen has a clipping ancestor that reaches past the fold. + */ + const clip_box = () => { + const bottom = window.innerHeight || 0; + let el = $row.get(0)?.parentElement; + while ( el && el !== document.body ) { + const style = getComputedStyle(el); + if ( /auto|scroll|hidden/.test(`${style.overflowY}${style.overflowX}`) ) { + const box = el.getBoundingClientRect(); + return { + top: Math.max(box.top, 0), + bottom: Math.min(box.bottom, bottom), + }; + } + el = el.parentElement; + } + return { top: 0, bottom }; + }; + + /** + * Fits the open list to the room around the field. Being out of the flow, + * it would otherwise be cut off by that clipping ancestor rather than + * scroll; where below is too tight and above is roomier, it opens upwards. + */ + const place = () => { + const row = $row.get(0)?.getBoundingClientRect(); + if ( ! row ) return; + const clip = clip_box(); + const below = clip.bottom - row.bottom - CHROME; + const above = row.top - clip.top - CHROME; + const up = below < MIN_HEIGHT && above > below; + $panel.toggleClass('share-suggest-above', up); + $panel.find('.share-suggest-list').css( + 'max-height', + `${Math.round(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, up ? above : below)))}px`, + ); + }; + + // -- Rendering -- + + /** The muted second line: what this row is. `i18n` encodes unless told not to. */ + const sub_line = (suggestion, encode) => { + if ( suggestion.kind === 'team' ) return i18n('share_suggest_team', {}, encode); + if ( suggestion.teamName ) { + return i18n('share_suggest_in_team', { team: suggestion.teamName }, encode); + } + return suggestion.recent ? i18n('share_suggest_recent', {}, encode) : ''; + }; + + const option_html = (suggestion, index) => { + const name = html_encode(suggestion.name); + const sub = sub_line(suggestion, true); + // The name alone reads as bare text to a screen reader, which cannot + // tell a team from a person; the label spells out which this is. + const raw_sub = sub_line(suggestion, false); + const aria = raw_sub ? `${suggestion.name} — ${raw_sub}` : suggestion.name; + const mark = suggestion.kind === 'team' + ? `` + : ``; + return ``; + }; + + const paint_active = () => { + $panel.find('.share-suggest-option').each(function (index) { + const on = index === active; + $(this).toggleClass('share-suggest-active', on).attr('aria-selected', on ? 'true' : 'false'); + }); + $input.attr( + 'aria-activedescendant', + active >= 0 ? `${list_id}-${active}` : null, + ); + }; + + const render = () => { + if ( destroyed ) return; + if ( ! loaded ) { + // A store that answers in a blink should not flash a box the next + // paint replaces — or, for the many accounts with nothing to + // suggest, removes again. Until the wait is worth saying, whatever + // the panel already shows stays, and a first open stays shut. + if ( ! waiting ) { + wait_timer ??= setTimeout(() => { + wait_timer = null; + waiting = true; + if ( open ) render(); + }, WAIT_GRACE); + return; + } + shown = []; + active = -1; + $panel.find('.share-suggest-list') + .html(``); + $panel.prop('hidden', false); + $input.attr('aria-expanded', 'true'); + place(); + paint_active(); + return; + } + stop_waiting(); + shown = current_suggestions(); + // Nothing to offer is not worth a panel saying so: what the user typed + // is a perfectly good recipient, and an empty box would only cover it. + if ( shown.length === 0 ) return close(); + active = -1; + $panel.find('.share-suggest-list') + .html(shown.map((suggestion, index) => option_html(suggestion, index)).join('')); + $panel.prop('hidden', false); + $input.attr('aria-expanded', 'true'); + place(); + paint_active(); + }; + + // -- Opening and closing -- + + const open_list = () => { + if ( picked ) return; + open = true; + render(); + if ( ! loaded ) ensure_loaded().then(() => { if ( open ) render(); }); + }; + + const close = () => { + open = false; + active = -1; + stop_waiting(); + $panel.prop('hidden', true); + $input.attr('aria-expanded', 'false').removeAttr('aria-activedescendant'); + }; + + /** Whether the list is on screen, as opposed to on its way there. */ + const listed = () => open && ! $panel.prop('hidden'); + + const move = (step) => { + if ( shown.length === 0 ) return; + active = active < 0 + ? (step > 0 ? 0 : shown.length - 1) + : (active + step + shown.length) % shown.length; + paint_active(); + $panel.find('.share-suggest-option').eq(active).get(0) + ?.scrollIntoView?.({ block: 'nearest' }); + }; + + // -- Choosing -- + + const pick = (suggestion) => { + if ( ! suggestion ) return; + picked = suggestion; + $input.val(suggestion.name).prop('readonly', true).attr('aria-readonly', 'true'); + $field.addClass('share-suggest-picked'); + $clear.prop('hidden', false); + $note.prop('hidden', suggestion.kind !== 'team'); + close(); + $input.get(0)?.focus({ preventScroll: true }); + onChange(); + }; + + /** Empties the field and hands it back to the keyboard. */ + const unpick = ({ focus = true } = {}) => { + if ( ! picked ) return; + picked = null; + $input.prop('readonly', false).removeAttr('aria-readonly').val(''); + $field.removeClass('share-suggest-picked'); + $clear.prop('hidden', true); + $note.prop('hidden', true); + if ( focus ) $input.get(0)?.focus({ preventScroll: true }); + onChange(); + }; + + // -- Input wiring -- + + // Opened by a real interaction, not by focus: both dialogs focus this field + // as they open, and a list unfurled before the user has looked at the + // dialog would push everything else out of view. + $input.on('click', () => open_list()); + + $input.on('input', () => { + open_list(); + onChange(); + }); + + $input.on('keydown', (e) => { + if ( e.key === 'ArrowDown' ) { + e.preventDefault(); + if ( picked ) return; + if ( open ) move(1); + else open_list(); + return; + } + if ( e.key === 'ArrowUp' ) { + e.preventDefault(); + if ( open ) move(-1); + return; + } + if ( e.key === 'Enter' ) { + if ( open && active >= 0 ) { + // Enter chooses the highlighted row; it must not also submit + // the form with whatever half-typed text is in the field. + e.preventDefault(); + pick(shown[active]); + } + return; + } + if ( e.key === 'Escape' ) { + // The dialogs close on Escape from the document; a list that is up + // spends the first press, but a list still loading spends nothing. + if ( ! listed() ) return; + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + if ( e.key === 'Tab' ) { + close(); + return; + } + if ( ! picked ) return; + if ( e.key === 'Backspace' || e.key === 'Delete' ) { + e.preventDefault(); + unpick(); + open_list(); + return; + } + // Typing over a locked field replaces what was chosen, rather than + // reading as a dead key. Unlocking here lets this keystroke land. + if ( e.key.length === 1 && ! e.metaKey && ! e.ctrlKey && ! e.altKey ) { + unpick({ focus: false }); + } + }); + + $clear.on('click', () => { + unpick(); + open_list(); + }); + + // Down first, so the field doesn't blur out from under the click. + $panel.on('mousedown', '.share-suggest-option', (e) => e.preventDefault()); + $panel.on('click', '.share-suggest-option', function () { + pick(shown[Number($(this).attr('data-index'))]); + }); + + const on_document_mousedown = (e) => { + if ( ! open ) return; + if ( $field.get(0)?.contains(e.target) || $panel.get(0)?.contains(e.target) ) return; + close(); + }; + $(document).on(`mousedown.${list_id}`, on_document_mousedown); + // The list follows the row on its own; only how much room it has changes. + $(window).on(`resize.${list_id}`, () => { if ( open ) place(); }); + + // -- What the dialog needs from it -- + + const recipient = () => { + if ( picked ) { + return { value: recipient_for(picked), label: picked.name, picked }; + } + const typed = String($input.val() ?? '').trim(); + return typed === '' ? null : { value: typed, label: typed, picked: null }; + }; + + /** + * What to file a successful share under. A typed address that turned out to + * belong to an account is filed under the username the backend resolved, so + * it lines up with the access list next time. + */ + const entry_for = (chosen, created) => { + if ( chosen.picked ) { + return { kind: chosen.picked.kind, id: chosen.picked.id, name: chosen.picked.name }; + } + const made = (Array.isArray(created) ? created : []).find(Boolean); + if ( made?.pending && made.recipientEmail ) { + return { kind: 'invite', id: made.recipientEmail, name: made.recipientEmail }; + } + if ( made?.holder ) { + return { kind: 'user', id: made.holder, name: made.holder }; + } + const typed = String(chosen.value); + return { kind: typed.includes('@') ? 'invite' : 'user', id: typed, name: typed }; + }; + + return { + recipient, + + setTeams: (next) => { + teams = Array.isArray(next) ? next : []; + // The roster follows from the teams, so it has to be looked up again. + loading = null; + loaded = false; + if ( open ) open_list(); + }, + + remember: (chosen, created) => { + if ( ! chosen ) return; + const entry = entry_for(chosen, created); + // The dialog stays usable whether or not the store takes it, so + // this neither blocks nor reports. + remember_recipient(entry).then((next) => { recents = next; }).catch(() => {}); + }, + + clear: () => { + unpick({ focus: false }); + $input.val(''); + close(); + onChange(); + }, + + close, + + destroy: () => { + destroyed = true; + close(); + $(document).off(`mousedown.${list_id}`); + $(window).off(`resize.${list_id}`); + }, + }; +} diff --git a/src/gui/src/helpers/shareRecipientPicker.test.js b/src/gui/src/helpers/shareRecipientPicker.test.js new file mode 100644 index 000000000..07bbc4bf9 --- /dev/null +++ b/src/gui/src/helpers/shareRecipientPicker.test.js @@ -0,0 +1,450 @@ +/* + * 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. + */ + +// @vitest-environment jsdom + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { encode } from 'html-entities'; + +const ACME = { uid: 't-1', name: 'Acme', handle: 'acme' }; + +let shareRecipientPicker; +let forget_colleagues; +let forget_recent_recipients; + +beforeAll(async () => { + const jquery = await import('../lib/jquery-3.6.1/jquery-3.6.1.min.js'); + globalThis.$ = globalThis.jQuery = jquery.default ?? window.jQuery; + globalThis.html_encode = (str) => encode(str); + await import('../i18n/i18n.js'); // installs window.i18n + globalThis.i18n = window.i18n; + // The list scrolls its active row into view; jsdom has no such method. + Element.prototype.scrollIntoView ??= () => {}; + + ({ default: shareRecipientPicker } = await import('./shareRecipientPicker.js')); + ({ forget_colleagues } = await import('./shareTeams.js')); + ({ forget_recent_recipients } = await import('./shareRecents.js')); +}); + +/** Every picker this test mounted, so none outlives its case. */ +let mounted = []; + +/** Renders the markup both dialogs share and attaches a picker to it. */ +const mount = ({ focus_first = false, ...opts } = {}) => { + document.body.innerHTML = ` +
+
+ + +
+
`; + const $input = $('.recipient'); + // Both dialogs focus the field before the picker is wired onto it. + if ( focus_first ) $input.get(0).focus(); + const picker = shareRecipientPicker({ + $input, + $row: $('.row'), + ...opts, + }); + mounted.push(picker); + return { picker, $input }; +}; + +const document_mousedown_count = () => + ($._data(document, 'events')?.mousedown ?? []).length; + +const options = () => $('.share-suggest-option').toArray() + .map((el) => $(el).find('.share-suggest-name').text()); + +/** Lets the picker's data load (colleagues + recents) and repaint. */ +const settle = async () => { + for ( let i = 0; i < 6; i++ ) await Promise.resolve(); +}; + +beforeEach(() => { + window.user = { username: 'me' }; + forget_colleagues(); + forget_recent_recipients(); + globalThis.puter = { + teams: { + listMembers: vi.fn(async () => [ + { username: 'me' }, + { username: 'bob' }, + { username: 'carol' }, + ]), + }, + kv: { + get: vi.fn(async () => [ + { kind: 'invite', id: 'ann@example.com', name: 'ann@example.com' }, + ]), + set: vi.fn(async () => true), + }, + }; +}); + +const default_viewport_height = 768; + +afterEach(() => { + mounted.forEach((picker) => picker.destroy()); + mounted = []; + document.body.innerHTML = ''; + window.innerHeight = default_viewport_height; +}); + +describe('wiring onto the field', () => { + it('keeps the focus the dialog put there before wiring', () => { + const { $input } = mount({ focus_first: true }); + // Moving the field into the wrapper takes it out of the document for + // an instant, which is enough to drop focus if nobody puts it back. + expect(document.activeElement).toBe($input.get(0)); + }); +}); + +describe('opening the list', () => { + it('stays shut until the field is actually used', async () => { + const { $input } = mount(); + // Both dialogs focus this field as they open; that must not unfurl a + // list over everything else before the user has looked at the dialog. + $input.trigger('focus'); + await settle(); + expect($('.share-suggest').prop('hidden')).toBe(true); + }); + + it('offers teams, colleagues and past recipients on a click', async () => { + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + expect($('.share-suggest').prop('hidden')).toBe(false); + expect(options()).toEqual(['ann@example.com', 'Acme', 'bob', 'carol']); + // Never the signed-in user themselves. + expect(options()).not.toContain('me'); + }); + + it('narrows to what is typed', async () => { + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + $input.val('ca').trigger('input'); + expect(options()).toEqual(['carol']); + }); + + it('closes rather than showing an empty box, since typed text is enough', async () => { + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + $input.val('nobody-here').trigger('input'); + expect($('.share-suggest').prop('hidden')).toBe(true); + }); + + it('floats off the row instead of taking part in the layout', async () => { + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + // Opening must not push anything down: the panel hangs off the row, + // which the anchor class makes a positioning context. + expect($('.row').hasClass('share-suggest-anchor')).toBe(true); + expect($('.share-suggest').parent().is('.row')).toBe(true); + // The list is capped to the room around the field, not left to grow. + expect($('.share-suggest-list').get(0).style.maxHeight).toMatch(/px$/); + }); + + // jsdom has no layout, so the boxes `place()` measures are stubbed: a + // scrolling ancestor holding the row, reaching past the bottom of the + // screen the way a dialog dragged low on a short display does. + const stub_layout = ({ viewport, clip, row }) => { + window.innerHeight = viewport; + const host = $('.host').get(0); + host.style.overflowY = 'auto'; + host.getBoundingClientRect = () => ({ top: clip[0], bottom: clip[1] }); + $('.row').get(0).getBoundingClientRect = () => ({ top: row[0], bottom: row[1] }); + }; + + it('caps the list at the room on screen, not the room in the dialog', async () => { + const { picker, $input } = mount(); + stub_layout({ viewport: 700, clip: [200, 780], row: [480, 517] }); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + // 700 - 517 - 24, not the 239 the clipping ancestor alone reports. + expect($('.share-suggest-list').get(0).style.maxHeight).toBe('159px'); + }); + + it('opens upwards when what is below the field is off the screen', async () => { + const { picker, $input } = mount(); + stub_layout({ viewport: 700, clip: [100, 900], row: [600, 637] }); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + expect($('.share-suggest').hasClass('share-suggest-above')).toBe(true); + }); + + it('holds the panel back rather than flashing a loading box', async () => { + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + + // A store that answers in a blink would otherwise put up a box that + // the next paint replaces — or, with nothing to suggest, removes. + expect($('.share-suggest').prop('hidden')).toBe(true); + await settle(); + expect(options()).toEqual(['ann@example.com', 'Acme', 'bob', 'carol']); + }); + + it('says it is loading once the wait is worth mentioning', async () => { + puter.kv.get = vi.fn(() => new Promise((resolve) => setTimeout(() => resolve([]), 600))); + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect($('.share-suggest').prop('hidden')).toBe(false); + expect($('.share-suggest-loading').length).toBe(1); + }); + + it('leaves Escape to the dialog while the list is still on its way', async () => { + puter.kv.get = vi.fn(() => new Promise((resolve) => setTimeout(() => resolve([]), 600))); + const { $input } = mount(); + $input.trigger('click'); + + const event = $.Event('keydown', { key: 'Escape' }); + $input.trigger(event); + expect(event.isPropagationStopped()).toBe(false); + }); + + it('leaves out whoever the access list already covers', async () => { + const { picker, $input } = mount({ excluded: () => ['user:bob', 'team:t-1'] }); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + + expect(options()).toEqual(['ann@example.com', 'carol']); + }); +}); + +describe('choosing someone', () => { + const open = async (opts) => { + const mounted = mount(opts); + mounted.picker.setTeams([ACME]); + mounted.$input.trigger('click'); + await settle(); + return mounted; + }; + + it('hands a team to the share call by uid, not by name', async () => { + const { picker } = await open(); + $('.share-suggest-option').eq(1).trigger('click'); + + expect(picker.recipient()).toMatchObject({ + value: { team: 't-1' }, + label: 'Acme', + }); + }); + + it('hands a typed address over untouched', async () => { + const { picker, $input } = mount(); + $input.val(' someone@example.com ').trigger('input'); + expect(picker.recipient()).toMatchObject({ + value: 'someone@example.com', + picked: null, + }); + }); + + it('resolves to nothing while the field is empty', () => { + const { picker } = mount(); + expect(picker.recipient()).toBe(null); + }); + + it('locks the field, so a team name cannot be edited into a username', async () => { + const { $input } = await open(); + $('.share-suggest-option').eq(1).trigger('click'); + + expect($input.val()).toBe('Acme'); + expect($input.prop('readonly')).toBe(true); + expect($('.share-suggest-clear').prop('hidden')).toBe(false); + // Sharing with a team reaches everyone in it, which is worth saying. + expect($('.share-suggest-note').prop('hidden')).toBe(false); + }); + + it('unlocks on the clear button and offers the list again', async () => { + const { picker } = await open(); + $('.share-suggest-option').eq(1).trigger('click'); + $('.share-suggest-clear').trigger('click'); + + expect(picker.recipient()).toBe(null); + expect($('.recipient').prop('readonly')).toBe(false); + expect($('.share-suggest-note').prop('hidden')).toBe(true); + expect($('.share-suggest').prop('hidden')).toBe(false); + }); + + it('unlocks on Backspace rather than reading as a dead key', async () => { + const { picker, $input } = await open(); + $('.share-suggest-option').eq(1).trigger('click'); + $input.trigger($.Event('keydown', { key: 'Backspace' })); + + expect(picker.recipient()).toBe(null); + expect($input.prop('readonly')).toBe(false); + }); + + it('replaces the choice when the user just types over it', async () => { + const { $input } = await open(); + $('.share-suggest-option').eq(1).trigger('click'); + $input.trigger($.Event('keydown', { key: 'x' })); + + // Unlocked and emptied inside the keydown, so the browser's own + // insertion of that keystroke lands in a field holding just it. + expect($input.prop('readonly')).toBe(false); + expect($input.val()).toBe(''); + }); + + it('tells the dialog whenever the field resolves to something new', async () => { + const onChange = vi.fn(); + const { $input } = await open({ onChange }); + onChange.mockClear(); + + $input.val('bo').trigger('input'); + expect(onChange).toHaveBeenCalledTimes(1); + $('.share-suggest-option').eq(0).trigger('click'); + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); + +describe('keyboard', () => { + const open = async () => { + const mounted = mount(); + mounted.picker.setTeams([ACME]); + mounted.$input.trigger('click'); + await settle(); + return mounted; + }; + + const press = ($input, key, extra = {}) => { + const event = $.Event('keydown', { key, ...extra }); + $input.trigger(event); + return event; + }; + + it('walks the list with the arrow keys and takes the row on Enter', async () => { + const { picker, $input } = await open(); + press($input, 'ArrowDown'); + press($input, 'ArrowDown'); + + expect($('.share-suggest-option').eq(1).attr('aria-selected')).toBe('true'); + expect($input.attr('aria-activedescendant')) + .toBe($('.share-suggest-option').eq(1).attr('id')); + + press($input, 'Enter'); + expect(picker.recipient().label).toBe('Acme'); + }); + + it('wraps around the ends rather than stopping dead', async () => { + const { $input } = await open(); + press($input, 'ArrowUp'); + expect($('.share-suggest-option').last().attr('aria-selected')).toBe('true'); + press($input, 'ArrowDown'); + expect($('.share-suggest-option').eq(0).attr('aria-selected')).toBe('true'); + }); + + it('leaves Enter to the form when no row is highlighted', async () => { + const { $input } = await open(); + const event = press($input, 'Enter'); + expect(event.isDefaultPrevented()).toBe(false); + }); + + it('spends the first Escape on the list, not on the dialog', async () => { + const { $input } = await open(); + const event = press($input, 'Escape'); + + expect($('.share-suggest').prop('hidden')).toBe(true); + // The dialogs close on Escape from the document; this one must not reach it. + expect(event.isPropagationStopped()).toBe(true); + + const second = press($input, 'Escape'); + expect(second.isPropagationStopped()).toBe(false); + }); + + it('closes on Tab so the list does not follow focus away', async () => { + const { $input } = await open(); + press($input, 'Tab'); + expect($('.share-suggest').prop('hidden')).toBe(true); + }); +}); + +describe('recording who was shared with', () => { + it('files a chosen team under its uid', async () => { + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + $('.share-suggest-option').eq(1).trigger('click'); + + picker.remember(picker.recipient(), [{ entryUid: 'e-1' }]); + await settle(); + expect(puter.kv.set).toHaveBeenCalledWith('recent_share_recipients', [ + { kind: 'team', id: 't-1', name: 'Acme' }, + { kind: 'invite', id: 'ann@example.com', name: 'ann@example.com' }, + ]); + }); + + it('files a typed address under the username the backend resolved', async () => { + const { picker, $input } = mount(); + $input.val('bob@example.com').trigger('input'); + + picker.remember(picker.recipient(), [{ holder: 'bob', isNew: true }]); + await settle(); + expect(puter.kv.set.mock.calls[0][1][0]) + .toEqual({ kind: 'user', id: 'bob', name: 'bob' }); + }); + + it('files an invitation under the address it went to', async () => { + const { picker, $input } = mount(); + $input.val('new@example.com').trigger('input'); + + picker.remember(picker.recipient(), [ + { pending: true, recipientEmail: 'new@example.com' }, + ]); + await settle(); + expect(puter.kv.set.mock.calls[0][1][0]) + .toEqual({ kind: 'invite', id: 'new@example.com', name: 'new@example.com' }); + }); + + it('records nothing when there was no recipient', async () => { + const { picker } = mount(); + picker.remember(null); + await settle(); + expect(puter.kv.set).not.toHaveBeenCalled(); + }); +}); + +describe('teardown', () => { + it('stops listening on the document when the dialog closes', async () => { + const before = document_mousedown_count(); + const { picker, $input } = mount(); + picker.setTeams([ACME]); + $input.trigger('click'); + await settle(); + expect(document_mousedown_count()).toBe(before + 1); + + picker.destroy(); + expect($('.share-suggest').prop('hidden')).toBe(true); + // A stray document handler would keep the detached dialog alive. + expect(document_mousedown_count()).toBe(before); + }); +}); diff --git a/src/gui/src/helpers/shareSuggest.js b/src/gui/src/helpers/shareSuggest.js new file mode 100644 index 000000000..c210a8024 --- /dev/null +++ b/src/gui/src/helpers/shareSuggest.js @@ -0,0 +1,210 @@ +/* + * 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 { team_label } from './shareTeams.js'; + +// What the recipient field offers when it is opened: who this account shared +// with before, the teams it belongs to, and the colleagues in them. Pure, so +// the ranking rules are testable without a DOM. + +/** How many rows the list shows at once; the rest is what typing is for. */ +export const MAX_SUGGESTIONS = 8; + +/** + * One offer in the list. + * + * @typedef {Object} Suggestion + * @property {string} key - Row identity, comparable with an access list's keys + * @property {'user'|'invite'|'team'} kind + * @property {string} id - Username, invited email address, or team uid + * @property {string} name - What to show + * @property {string|null} teamName - The team a colleague was found through + * @property {boolean} recent - Whether this account shared with them before + */ + +/** + * Row identity: `user:`, `invite:` or `team:`, matching + * the access list's keys so a person already on it can be left out. + * + * @param {{ kind: string, id: string }} entry + * @returns {string} + */ +export const recipient_key = ({ kind, id }) => + `${kind}:${String(id ?? '').toLowerCase()}`; + +/** + * The same key for a share as listed by the backend, so a dialog holding raw + * listings can say who is already covered. + * + * @param {Object} share + * @returns {string|null} null for a link share, which is nobody in particular + */ +export const share_key = (share) => { + if ( share?.anyone ) return null; + if ( share?.holderTeam?.uid ) return recipient_key({ kind: 'team', id: share.holderTeam.uid }); + if ( share?.pending ) { + return share.recipientEmail + ? recipient_key({ kind: 'invite', id: share.recipientEmail }) + : null; + } + return share?.holder ? recipient_key({ kind: 'user', id: share.holder }) : null; +}; + +/** + * What a share call should be given for this suggestion. A team is named by + * uid, since a bare string is read as an email address or a username. + * + * @param {Suggestion} suggestion + * @returns {string|{ team: string }} + */ +export const recipient_for = (suggestion) => + (suggestion.kind === 'team' ? { team: suggestion.id } : suggestion.id); + +/** + * How well a candidate answers what was typed: 0 for a leading match, 1 for a + * match anywhere, -1 for none. + * + * @param {string[]} haystacks + * @param {string} needle - already lowercased and trimmed + * @returns {number} + */ +const match_score = (haystacks, needle) => { + let best = -1; + for ( const text of haystacks ) { + const lower = text.toLowerCase(); + if ( lower.startsWith(needle) ) return 0; + if ( best === -1 && lower.includes(needle) ) best = 1; + } + return best; +}; + +/** + * What to offer in the recipient field. + * + * With nothing typed the order is recents, then teams, then colleagues — the + * shortcut first, the broad grant next, the roster last. Typing ranks leading + * matches above matches anywhere and keeps that order within each tier. + * + * A recent team the user has since left is dropped: it can no longer be shared + * with, and its stored name may be stale. Someone already on the access list is + * dropped too — the list below the field already accounts for them. + * + * @param {Object} [options] + * @param {string} [options.query] - What is typed in the field + * @param {Array<{ uid: string, name?: string|null, handle?: string|null }>} [options.teams] + * @param {Array<{ username: string, teamName?: string|null }>} [options.members] + * @param {import('./shareRecents.js').RecentRecipient[]} [options.recents] + * @param {Set|Iterable} [options.exclude] - Keys already covered + * @param {string|null} [options.self] - The signed-in username, never offered + * @param {number} [options.limit] + * @returns {Suggestion[]} + */ +export const build_suggestions = ({ + query = '', + teams = [], + members = [], + recents = [], + exclude = [], + self = null, + limit = MAX_SUGGESTIONS, +} = {}) => { + const live_teams = new Map(teams.map((team) => [team.uid, team])); + const member_teams = new Map( + members.map((member) => [String(member.username).toLowerCase(), member.teamName ?? null]), + ); + const excluded = new Set([...exclude].map((key) => String(key).toLowerCase())); + const own = self ? String(self).toLowerCase() : null; + + /** @type {Array} */ + const candidates = []; + const seen = new Set(); + + const push = (entry, tier, search) => { + const key = recipient_key(entry); + if ( seen.has(key) || excluded.has(key) ) return; + if ( entry.kind === 'user' && own && entry.id.toLowerCase() === own ) return; + seen.add(key); + candidates.push({ ...entry, key, tier, search: search.filter(Boolean) }); + }; + + for ( const recent of recents ) { + if ( recent.kind === 'team' ) { + const team = live_teams.get(recent.id); + if ( ! team ) continue; + push( + { kind: 'team', id: team.uid, name: team_label(team), teamName: null, recent: true }, + 0, + [team_label(team), team.handle], + ); + continue; + } + // A colleague shared with before is still worth placing by their team. + const teamName = recent.kind === 'user' + ? member_teams.get(recent.id.toLowerCase()) ?? null + : null; + push( + { kind: recent.kind, id: recent.id, name: recent.name, teamName, recent: true }, + 0, + [recent.name, recent.id], + ); + } + + for ( const team of teams ) { + push( + { kind: 'team', id: team.uid, name: team_label(team), teamName: null, recent: false }, + 1, + [team_label(team), team.handle], + ); + } + + for ( const member of members ) { + if ( ! member?.username ) continue; + push( + { + kind: 'user', + id: member.username, + name: member.username, + teamName: member.teamName ?? null, + recent: false, + }, + 2, + [member.username], + ); + } + + const strip = ({ key, kind, id, name, teamName, recent }) => + ({ key, kind, id, name, teamName, recent }); + + const needle = query.trim().toLowerCase(); + if ( needle === '' ) return candidates.slice(0, limit).map(strip); + + return candidates + .map((candidate, index) => ({ + candidate, + index, + score: match_score(candidate.search, needle), + })) + .filter((entry) => entry.score >= 0) + .sort((a, b) => + a.score - b.score || + a.candidate.tier - b.candidate.tier || + a.index - b.index) + .slice(0, limit) + .map((entry) => strip(entry.candidate)); +}; diff --git a/src/gui/src/helpers/shareSuggest.test.js b/src/gui/src/helpers/shareSuggest.test.js new file mode 100644 index 000000000..48d80f509 --- /dev/null +++ b/src/gui/src/helpers/shareSuggest.test.js @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + build_suggestions, + recipient_for, + recipient_key, + share_key, +} from './shareSuggest.js'; + +const ACME = { uid: 't-1', name: 'Acme', handle: 'acme' }; +const OPS = { uid: 't-2', name: null, handle: 'ops' }; + +const names = (list) => list.map((entry) => entry.name); +const keys = (list) => list.map((entry) => entry.key); + +describe('recipient_key', () => { + it('keys a person case-insensitively, since an address is', () => { + expect(recipient_key({ kind: 'invite', id: 'Ann@Example.com' })) + .toBe('invite:ann@example.com'); + }); +}); + +describe('share_key', () => { + it('keys a listed share the way the access list does', () => { + expect(share_key({ holder: 'bob' })).toBe('user:bob'); + expect(share_key({ holderTeam: ACME })).toBe('team:t-1'); + expect(share_key({ pending: true, recipientEmail: 'ann@example.com' })) + .toBe('invite:ann@example.com'); + }); + + it('has no key for a link share, which is nobody in particular', () => { + expect(share_key({ anyone: true, mode: 'read' })).toBe(null); + }); +}); + +describe('recipient_for', () => { + it('names a team by uid, because a bare string reads as a person', () => { + expect(recipient_for({ kind: 'team', id: 't-1', name: 'Acme' })) + .toEqual({ team: 't-1' }); + expect(recipient_for({ kind: 'user', id: 'bob', name: 'bob' })).toBe('bob'); + }); +}); + +describe('build_suggestions', () => { + const base = { + teams: [ACME, OPS], + members: [ + { username: 'bob', teamName: 'Acme' }, + { username: 'carol', teamName: 'Acme' }, + ], + recents: [{ kind: 'invite', id: 'ann@example.com', name: 'ann@example.com' }], + }; + + it('offers recents first, then teams, then colleagues', () => { + expect(names(build_suggestions(base))).toEqual([ + 'ann@example.com', + 'Acme', + 'ops', + 'bob', + 'carol', + ]); + }); + + it('never offers the signed-in user their own name', () => { + const out = build_suggestions({ ...base, self: 'bob' }); + expect(names(out)).not.toContain('bob'); + }); + + it('leaves out anyone the access list already covers', () => { + const out = build_suggestions({ + ...base, + exclude: ['team:t-1', 'invite:ANN@example.com'], + }); + expect(keys(out)).toEqual(['team:t-2', 'user:bob', 'user:carol']); + }); + + it('lists a colleague once even when they are also a recent', () => { + const out = build_suggestions({ + ...base, + recents: [{ kind: 'user', id: 'bob', name: 'bob' }], + }); + expect(keys(out).filter((key) => key === 'user:bob')).toHaveLength(1); + // Placed as a recent, but still named by the team they were found in. + expect(out[0]).toMatchObject({ name: 'bob', recent: true, teamName: 'Acme' }); + }); + + it('drops a recent team the user no longer belongs to', () => { + const out = build_suggestions({ + ...base, + teams: [OPS], + recents: [{ kind: 'team', id: 't-1', name: 'Acme' }], + }); + expect(names(out)).not.toContain('Acme'); + }); + + it('renames a recent team from the live list rather than what was stored', () => { + const out = build_suggestions({ + teams: [{ ...ACME, name: 'Acme Corp' }], + recents: [{ kind: 'team', id: 't-1', name: 'Acme' }], + }); + expect(names(out)).toEqual(['Acme Corp']); + }); + + it('ranks a leading match above a match anywhere', () => { + const out = build_suggestions({ + ...base, + members: [{ username: 'roberta', teamName: 'Acme' }, { username: 'bob', teamName: 'Acme' }], + recents: [], + query: 'ob', + }); + // "bob" only contains it; "roberta" does too — order holds within the + // tier, and neither leads, so the roster order decides. + expect(names(out)).toEqual(['roberta', 'bob']); + expect(names(build_suggestions({ ...base, recents: [], query: 'bo' }))) + .toEqual(['bob']); + }); + + it('matches a team by its handle as well as its name', () => { + expect(names(build_suggestions({ ...base, recents: [], query: 'acm' }))) + .toEqual(['Acme']); + expect(names(build_suggestions({ ...base, recents: [], query: 'ops' }))) + .toEqual(['ops']); + }); + + it('does not match a team by its uid, which nobody types', () => { + expect(build_suggestions({ ...base, query: 't-1' })).toEqual([]); + }); + + it('keeps a recent ahead of a team when both match equally well', () => { + const out = build_suggestions({ + teams: [{ uid: 't-9', name: 'Ann', handle: null }], + recents: [{ kind: 'user', id: 'ann', name: 'ann' }], + query: 'ann', + }); + expect(keys(out)).toEqual(['user:ann', 'team:t-9']); + }); + + it('stops at the limit rather than filling the dialog', () => { + const members = Array.from({ length: 30 }, (_, i) => ({ username: `u${i}` })); + expect(build_suggestions({ members })).toHaveLength(8); + expect(build_suggestions({ members, limit: 3 })).toHaveLength(3); + }); + + it('offers nothing when there is nothing to offer', () => { + expect(build_suggestions()).toEqual([]); + }); +}); diff --git a/src/gui/src/helpers/shareTeams.js b/src/gui/src/helpers/shareTeams.js index bf2ec2b3b..a2b9a2824 100644 --- a/src/gui/src/helpers/shareTeams.js +++ b/src/gui/src/helpers/shareTeams.js @@ -39,6 +39,78 @@ export const teams_for_sharing = async () => { } }; +// One page per team is enough to suggest from, and it is the route's ceiling — +// without it the SDK walks every page of a large roster. +const MEMBER_PAGE_CAP = 200; + +/** Rosters change rarely; long enough that reopening the dialog is free. */ +const COLLEAGUE_TTL = 5 * 60 * 1000; + +/** @type {{ key: string, at: number, promise: Promise } | null} */ +let colleague_cache = null; + +/** @typedef {{ username: string, teamName: string | null }} Colleague */ + +/** + * The people in `teams`, deduplicated across them and named by the first team + * they were found in — the pool the sharing dialogs suggest from. + * + * Never rejects: a roster the caller may not read (an unverified account, an + * older SDK, a deployment with teams off) means "suggest nobody". A lookup that + * failed outright is not cached, so the next dialog tries again. + * + * @param {Team[]} teams + * @returns {Promise} + */ +export const colleagues_for_sharing = async (teams) => { + const list = Array.isArray(teams) ? teams.filter((team) => team?.uid) : []; + if ( list.length === 0 ) return []; + + const key = list.map((team) => team.uid).sort().join(','); + const now = Date.now(); + if ( colleague_cache?.key === key && now - colleague_cache.at < COLLEAGUE_TTL ) { + return colleague_cache.promise; + } + + const promise = (async () => { + // One team's roster being out of reach must not withhold the others. + let settled; + try { + settled = await Promise.allSettled(list.map( + (team) => puter.teams.listMembers(team.uid, { limit: MEMBER_PAGE_CAP }), + )); + } catch { + settled = []; + } + /** @type {Map} */ + const by_username = new Map(); + settled.forEach((result, index) => { + if ( result.status !== 'fulfilled' ) return; + for ( const member of result.value ?? [] ) { + const username = member?.username; + if ( ! username || by_username.has(username) ) continue; + by_username.set(username, { username, teamName: team_label(list[index]) }); + } + }); + return [...by_username.values()]; + })(); + + colleague_cache = { key, at: now, promise }; + // A roster always names at least the caller, so coming back with nobody + // means every lookup was refused — not something to hold on to. + promise.then((colleagues) => { + if ( colleagues.length === 0 && colleague_cache?.promise === promise ) { + colleague_cache = null; + } + }); + return promise; +}; + +/** Drops the roster cache. For tests, and for a change of signed-in user. */ +export const forget_colleagues = () => { + colleague_cache = null; +}; + /** * The team a share is held by, or `null` when a person holds it. * diff --git a/src/gui/src/helpers/shareTeams.test.js b/src/gui/src/helpers/shareTeams.test.js index f2fab7997..30a736823 100644 --- a/src/gui/src/helpers/shareTeams.test.js +++ b/src/gui/src/helpers/shareTeams.test.js @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + colleagues_for_sharing, + forget_colleagues, team_for_share, team_label, teams_for_sharing, @@ -15,6 +17,7 @@ const original_puter = globalThis.puter; globalThis.window = globalThis.window ?? {}; beforeEach(() => { window.teams_ui = true; + forget_colleagues(); }); afterEach(() => { @@ -62,6 +65,84 @@ describe('teams_for_sharing', () => { }); }); +describe('colleagues_for_sharing', () => { + const OTHER = { uid: 't-3', name: 'Ops', handle: 'ops' }; + + it('names each colleague by the team they were found in', async () => { + globalThis.puter = { + teams: { + listMembers: vi.fn(async (uid) => (uid === 't-1' + ? [{ username: 'bob' }, { username: 'carol' }] + : [{ username: 'dave' }])), + }, + }; + await expect(colleagues_for_sharing([ACME, OTHER])).resolves.toEqual([ + { username: 'bob', teamName: 'Acme' }, + { username: 'carol', teamName: 'Acme' }, + { username: 'dave', teamName: 'Ops' }, + ]); + }); + + it('lists someone in two teams once, under the first', async () => { + globalThis.puter = { + teams: { listMembers: vi.fn(async () => [{ username: 'bob' }]) }, + }; + await expect(colleagues_for_sharing([ACME, OTHER])).resolves.toEqual([ + { username: 'bob', teamName: 'Acme' }, + ]); + }); + + it('keeps the teams it could read when another is out of reach', async () => { + globalThis.puter = { + teams: { + listMembers: vi.fn(async (uid) => { + if ( uid === 't-1' ) throw new Error('forbidden'); + return [{ username: 'dave' }]; + }), + }, + }; + await expect(colleagues_for_sharing([ACME, OTHER])).resolves.toEqual([ + { username: 'dave', teamName: 'Ops' }, + ]); + }); + + it('asks once per set of teams, so reopening the dialog is free', async () => { + const listMembers = vi.fn(async () => [{ username: 'bob' }]); + globalThis.puter = { teams: { listMembers } }; + + await colleagues_for_sharing([ACME]); + await colleagues_for_sharing([ACME]); + expect(listMembers).toHaveBeenCalledTimes(1); + + // A different set of teams is a different question. + await colleagues_for_sharing([ACME, OTHER]); + expect(listMembers).toHaveBeenCalledTimes(3); + }); + + it('caps the roster at one page rather than walking every one', async () => { + const listMembers = vi.fn(async () => []); + globalThis.puter = { teams: { listMembers } }; + await colleagues_for_sharing([ACME]); + expect(listMembers).toHaveBeenCalledWith('t-1', { limit: 200 }); + }); + + it('does not remember a failure, so the next dialog tries again', async () => { + const listMembers = vi.fn(async () => { throw new Error('offline'); }); + globalThis.puter = { teams: { listMembers } }; + + await expect(colleagues_for_sharing([ACME])).resolves.toEqual([]); + await colleagues_for_sharing([ACME]); + expect(listMembers).toHaveBeenCalledTimes(2); + }); + + it('suggests nobody without a team, or against an SDK too old for it', async () => { + globalThis.puter = {}; + await expect(colleagues_for_sharing([])).resolves.toEqual([]); + await expect(colleagues_for_sharing(undefined)).resolves.toEqual([]); + await expect(colleagues_for_sharing([ACME])).resolves.toEqual([]); + }); +}); + describe('team_for_share', () => { it('takes the team the share names, whoever the caller is', () => { // A team share has no holder user, so `holder` is empty and the diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 57fe3a89f..d2937a2ea 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -423,6 +423,13 @@ const en = { share_access_level: 'Access level', share_access_level_for: 'Access level for {{recipient}}', share_add_people: 'Add people by email or username', + share_add_people_teams: 'Add people or teams', + share_suggestions: 'Suggestions', + share_suggest_loading: 'Loading suggestions…', + share_suggest_team: 'Team', + share_suggest_in_team: 'in {{team}}', + share_suggest_recent: 'Recently shared', + share_clear_recipient: 'Clear', share_who_has_access: 'Who has access', share_no_one: 'Not shared with anyone yet.', share_owner: 'Owner', @@ -493,7 +500,6 @@ const en = { share_you: 'you', share_inherited_via: 'via {{folder}}', share_with: 'Share with:', - share_with_team: 'Or share with a team:', share_team_note: 'Everyone in the team gets access, including anyone added to it later.', share_row_team: 'team',