One recipient field in the sharing dialogs, with suggestions (#3897)

* 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
<body> 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&apos;é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.
This commit is contained in:
Nariman Jelveh
2026-09-17 13:54:03 -07:00
committed by GitHub
parent 51ce868d1e
commit d1484af754
15 changed files with 2012 additions and 202 deletions
+40 -57
View File
@@ -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
<span class="share-modal-submit-label">${i18n('share')}</span>
</button>
</form>
<div class="share-modal-teams" hidden></div>
${allow_manage ? `<div class="share-modal-general">
<h3 class="share-modal-heading">${i18n('share_general_access')}</h3>
<div class="share-modal-general-row">
@@ -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) => `<option value="${html_encode(team.uid)}">${html_encode(team_label(team))}</option>`)
.join('');
$overlay.find('.share-modal-teams').html(`
<label class="share-modal-teams-label" for="${teams_select_id}">${i18n('share_with_team')}</label>
<div class="share-modal-add-row">
<select class="share-modal-team-select" id="${teams_select_id}">${options}</select>
<select class="share-modal-team-mode" aria-label="${i18n('share_access_level')}">${options_for('read', { allow_manage })}</select>
</div>
<p class="share-modal-teams-note">${i18n('share_team_note')}</p>
<button type="button" class="share-modal-team-btn">${i18n('share')}</button>
`).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 });
+42 -58
View File
@@ -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 += `<label for="share-recipient">${i18n('share_with')}</label>`;
h += '<div class="share-dialog-row">';
h += `<input class="share-recipient" id="share-recipient" type="text" autocomplete="off" spellcheck="false"
placeholder="${html_encode(i18n('share_add_people'))}" />`;
placeholder="${i18n('share_add_people')}" />`;
h += `<select class="share-mode">${options_for('read', { allow_manage })}</select>`;
h += '</div>';
h += `<button class="share-btn button button-primary button-block button-normal">${i18n('share')}</button>`;
// 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 += '<div class="share-team" style="display:none;">';
h += `<label for="share-team-select">${i18n('share_with_team')}</label>`;
h += '<div class="share-dialog-row">';
h += '<select class="share-team-select" id="share-team-select"></select>';
h += `<select class="share-team-mode">${options_for('read', { allow_manage })}</select>`;
h += '</div>';
h += `<p class="share-team-note">${i18n('share_team_note')}</p>`;
h += `<button class="share-team-btn button button-block button-normal">${i18n('share')}</button>`;
h += '</div>';
// 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 => `<option value="${html_encode(team.uid)}">${html_encode(team_label(team))}</option>`)
.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;
}
+19 -65
View File
@@ -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 {
+202 -21
View File
@@ -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 {
+128
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// 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<RecentRecipient[]>}
*/
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<RecentRecipient[]>} 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;
};
+110
View File
@@ -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]);
});
});
+505
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 = `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>`;
const clearIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
// 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 `<input>`.
*
* 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<string>} [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<Object>) => void,
* remember: (chosen: ChosenRecipient|null, created?: Array<Object>) => 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 = $('<div class="share-suggest-field"></div>');
// 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 = $(`<button type="button" class="share-suggest-clear" hidden
aria-label="${i18n('share_clear_recipient')}" title="${i18n('share_clear_recipient')}">${clearIcon}</button>`);
$field.append($clear);
const $panel = $(`<div class="share-suggest" hidden>
<ul class="share-suggest-list" id="${list_id}" role="listbox" aria-label="${i18n('share_suggestions')}"></ul>
</div>`);
const $note = $(`<p class="share-suggest-note" hidden>${i18n('share_team_note')}</p>`);
// 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<Object>} */ let teams = [];
/** @type {Array<Object>} */ let members = [];
/** @type {Array<Object>} */ let recents = [];
/** @type {Promise<void>|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<typeof setTimeout>|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'
? `<span class="share-suggest-avatar share-suggest-avatar-team" aria-hidden="true">${teamIcon}</span>`
: `<span class="share-suggest-avatar" style="--share-avatar-hue: ${avatarHue(suggestion.name)}" aria-hidden="true">${html_encode(avatarInitial(suggestion.name))}</span>`;
return `<li class="share-suggest-option" role="option" aria-selected="false"
id="${list_id}-${index}" data-index="${index}" aria-label="${html_encode(aria)}">
${mark}
<span class="share-suggest-text">
<span class="share-suggest-name">${name}</span>
${sub ? `<span class="share-suggest-sub">${sub}</span>` : ''}
</span>
</li>`;
};
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(`<li class="share-suggest-loading" role="presentation">${i18n('share_suggest_loading')}</li>`);
$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}`);
},
};
}
@@ -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 = `
<div class="host">
<div class="row">
<input type="text" class="recipient" />
<select class="mode"><option value="read">read</option></select>
</div>
</div>`;
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);
});
});
+210
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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:<username>`, `invite:<email>` or `team:<uid>`, 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<string>|Iterable<string>} [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<Suggestion & { tier: number, search: string[] }>} */
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));
};
+146
View File
@@ -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([]);
});
});
+72
View File
@@ -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<Colleague[]> } | 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<Colleague[]>}
*/
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<string, Colleague>} */
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.
*
+81
View File
@@ -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
+7 -1
View File
@@ -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',