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(`
-
-
-
-
-
-
${i18n('share_team_note')}
-
- `).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 += ``;
+ placeholder="${i18n('share_add_people')}" />`;
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 += '
';
- h += ``;
- h += '
';
- h += '';
- h += ``;
- h += '
';
- h += `
${i18n('share_team_note')}
`;
- h += ``;
- 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