From d1484af75438d1319d6af85bc609cd0e3605afdf Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 17 Sep 2026 13:54:03 -0700 Subject: [PATCH] One recipient field in the sharing dialogs, with suggestions (#3897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: one recipient field in the sharing dialogs, with suggestions Both sharing dialogs asked twice: a text field for a person, and a separate select, label, note and button for a team. Which control to use was a fact about the API — a bare string is read as an email or a username, so a team could not be typed — not something a user should have to know. Now there is one field. Clicking it offers who this account shared with before, the teams it belongs to, and the people in them; typing narrows the list. Choosing an offer locks the field to that recipient, so a team picked by name still goes out by uid, and the note about a team grant reaching everyone in it appears at the moment it applies. Anyone already on the access list is left out of the offers, and a typed address still works untouched. Past recipients are kept in the account's key-value store, so they follow the user between browsers; a typed address that turned out to belong to an account is filed under the username the backend resolved. Colleagues are read one page per team, cached for five minutes, and only looked up once the field is actually used. The picker is one helper shared by both dialogs so the two can't drift, styled through `share-suggest-*` tokens each host restates in its own palette. It opens in the flow rather than floating, which a scrolling modal body and a mobile bottom sheet would otherwise clip. * fix: float the recipient suggestions instead of resizing the dialog The list opened in the flow, which pushed the Share button and everything under it down and back up as it opened and closed — a dialog that resizes under the cursor. It now hangs off the recipient row, out of the flow, and floats over what follows it. It goes inside the row rather than after it, so the row is its containing block; as a sibling it resolved against whatever was positioned further up and landed at the bottom of the scrolling body. Being out of flow it can be cut off by that scroll container rather than scrolling itself, so on open it measures the room between the row and the nearest clipping ancestor, caps the list to it, and opens upwards where below is too tight. * fix: keep the recipient field focused when the picker is wired onto it Both sharing dialogs focus the recipient field as they open, and the picker then moves that field into its wrapper. Moving an element takes it out of the document for an instant, which drops its focus, so every dialog opened with the caret in the field opened with the caret on instead. * fix: keep the recipient suggestions inside the screen The list sized itself against the nearest clipping ancestor alone. That ancestor is the dialog's own scrolling body, which reaches past the bottom of a short screen — or of any screen once the dialog has been dragged low — so the list was placed below the fold and the user saw nothing at all. The viewport bounds it too. * fix: stop the suggestions flashing a loading box over the dialog Clicking the recipient field put up a panel saying it was loading before it knew there was anything to load. For the many accounts with no teams and nobody shared with before, that panel covered the Share button for as long as the round trip took and then vanished again. It now waits a beat before saying anything, and leaves whatever is already listed in place while a reload is in flight. A list still on its way also no longer swallows the Escape that closes the dialog. * fix: do not double-encode the recipient field's placeholder `i18n()` encodes what it returns, which is right for a string dropped into markup and wrong for one handed to `.attr()` — the entities show up as themselves. Nothing is lost today because only English carries the key, but the next translation with an apostrophe in it would read `l'équipe`. * chore: drop the styles for the team picker the field replaced The desktop dialog's separate team control went with the one recipient field; its rules stayed behind. The dashboard's equivalents were already removed with its own markup. * fix: let Enter share from the desktop dialog's recipient field Typing a name and pressing Enter did nothing there: the field is in no form, so the only way to send was to reach the button. The Dashboard's dialog has always submitted on Enter, and the field now takes Enter to choose a suggestion, which makes a second press that does nothing read as a dead key. The picker still gets the press first while it is choosing a row. --- src/gui/src/UI/Dashboard/UIShareModal.js | 97 ++-- src/gui/src/UI/UIWindowShare.js | 100 ++-- src/gui/src/css/dashboard.css | 84 +-- src/gui/src/css/style.css | 223 +++++++- .../{UI/Dashboard => helpers}/shareAvatar.js | 0 .../Dashboard => helpers}/shareAvatar.test.js | 0 src/gui/src/helpers/shareRecents.js | 128 +++++ src/gui/src/helpers/shareRecents.test.js | 110 ++++ src/gui/src/helpers/shareRecipientPicker.js | 505 ++++++++++++++++++ .../src/helpers/shareRecipientPicker.test.js | 450 ++++++++++++++++ src/gui/src/helpers/shareSuggest.js | 210 ++++++++ src/gui/src/helpers/shareSuggest.test.js | 146 +++++ src/gui/src/helpers/shareTeams.js | 72 +++ src/gui/src/helpers/shareTeams.test.js | 81 +++ src/gui/src/i18n/translations/en.js | 8 +- 15 files changed, 2012 insertions(+), 202 deletions(-) rename src/gui/src/{UI/Dashboard => helpers}/shareAvatar.js (100%) rename src/gui/src/{UI/Dashboard => helpers}/shareAvatar.test.js (100%) create mode 100644 src/gui/src/helpers/shareRecents.js create mode 100644 src/gui/src/helpers/shareRecents.test.js create mode 100644 src/gui/src/helpers/shareRecipientPicker.js create mode 100644 src/gui/src/helpers/shareRecipientPicker.test.js create mode 100644 src/gui/src/helpers/shareSuggest.js create mode 100644 src/gui/src/helpers/shareSuggest.test.js 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 ? `