feat(scheduling-webui): manage participant invitations
This commit is contained in:
@@ -5,6 +5,8 @@ import {
|
||||
CalendarCheck,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Link2Off,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
DataGridRowActions,
|
||||
DateTimeField,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
isApiError,
|
||||
usePlatformLanguage,
|
||||
usePlatformUiCapability,
|
||||
useUnsavedChanges,
|
||||
@@ -54,15 +58,18 @@ import {
|
||||
decideSchedulingRequest,
|
||||
evaluateSchedulingFreeBusy,
|
||||
getSchedulingAvailabilityResponse,
|
||||
issueSchedulingParticipantInvitation,
|
||||
listSchedulingNotifications,
|
||||
listSchedulingRequests,
|
||||
openSchedulingRequest,
|
||||
revokeSchedulingParticipantInvitation,
|
||||
searchSchedulingPeople,
|
||||
schedulingSummary,
|
||||
submitSchedulingAvailability,
|
||||
updateSchedulingRequest,
|
||||
type SchedulingCandidateSlot,
|
||||
type SchedulingAvailabilityValue,
|
||||
type SchedulingInvitationActionResponse,
|
||||
type SchedulingNotification,
|
||||
type SchedulingParticipant,
|
||||
type SchedulingPollOptionResult,
|
||||
@@ -76,10 +83,13 @@ import {
|
||||
participantDraftFromResponse,
|
||||
participantDraftsFromPicker,
|
||||
participantPayload,
|
||||
schedulingInvitationActionBlocks,
|
||||
schedulingPublicInvitationUrl,
|
||||
schedulingRelevantTimestamp,
|
||||
schedulingRequestIsOwned,
|
||||
schedulingSortPhase,
|
||||
type SchedulingActor,
|
||||
type SchedulingInvitationActionBlock,
|
||||
type SchedulingParticipantDraft,
|
||||
type SchedulingRequestGroups
|
||||
} from "./schedulingViewModel";
|
||||
@@ -97,6 +107,10 @@ type SlotDraft = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
type ParticipantDraft = SchedulingParticipantDraft;
|
||||
type InvitationRevokeTarget = {
|
||||
requestId: string;
|
||||
participant: SchedulingParticipant;
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
actions: "i18n:govoplan-core.actions.c3cd636a",
|
||||
@@ -118,10 +132,13 @@ const I18N = {
|
||||
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
|
||||
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
|
||||
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
|
||||
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
|
||||
checkFreeBusy: "i18n:govoplan-scheduling.check_free_busy.e9700e00",
|
||||
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
|
||||
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
|
||||
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
|
||||
closed: "i18n:govoplan-scheduling.closed.88d86b77",
|
||||
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
|
||||
description: "i18n:govoplan-scheduling.description.55f8ebc8",
|
||||
determined: "i18n:govoplan-scheduling.determined.9f23293d",
|
||||
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
|
||||
@@ -133,7 +150,13 @@ const I18N = {
|
||||
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
|
||||
free: "i18n:govoplan-scheduling.free.75f52718",
|
||||
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
|
||||
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
|
||||
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
|
||||
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
|
||||
invitationChanged: "i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533",
|
||||
invitedRequests: "i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba",
|
||||
invitationLinkCopied: "i18n:govoplan-scheduling.invitation_link_copied.332973ec",
|
||||
invitationLinkRevoked: "i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4",
|
||||
loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d",
|
||||
location: "i18n:govoplan-scheduling.location.d219c681",
|
||||
allowComments: "i18n:govoplan-scheduling.allow_comments.d63202a6",
|
||||
@@ -155,6 +178,8 @@ const I18N = {
|
||||
myRequests: "i18n:govoplan-scheduling.my_scheduling_requests.d28ef235",
|
||||
name: "i18n:govoplan-scheduling.name.709a2322",
|
||||
newRequest: "i18n:govoplan-scheduling.new_scheduling_request.2080f675",
|
||||
noActiveInvitation: "i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc",
|
||||
noDeliveryTarget: "i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180",
|
||||
noNotifications: "i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3",
|
||||
notificationsUnavailable: "i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3",
|
||||
noParticipants: "i18n:govoplan-scheduling.no_participants.73a89101",
|
||||
@@ -165,6 +190,7 @@ const I18N = {
|
||||
notifyOnAnswersHelp: "i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8",
|
||||
open: "i18n:govoplan-scheduling.open.cf9b7706",
|
||||
openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7",
|
||||
participant: "i18n:govoplan-scheduling.participant.554f4235",
|
||||
participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
|
||||
participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a",
|
||||
participantPrivacy: "i18n:govoplan-scheduling.participant_privacy.108c470f",
|
||||
@@ -181,7 +207,12 @@ const I18N = {
|
||||
capacity: "i18n:govoplan-scheduling.capacity.d3c375f8",
|
||||
past: "i18n:govoplan-scheduling.past.405c12fb",
|
||||
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
|
||||
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
|
||||
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
|
||||
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
|
||||
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
|
||||
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
|
||||
revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1",
|
||||
requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c",
|
||||
publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0",
|
||||
policyLocked: "i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740",
|
||||
@@ -199,6 +230,7 @@ const I18N = {
|
||||
unsavedResponse: "i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1",
|
||||
singleChoice: "i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c",
|
||||
singleChoiceHelp: "i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465",
|
||||
sendInvitation: "i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea",
|
||||
sendResponse: "i18n:govoplan-scheduling.submit_response.a5f0c053",
|
||||
start: "i18n:govoplan-scheduling.start.952f3754",
|
||||
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
|
||||
@@ -255,6 +287,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
const [summaryUnavailable, setSummaryUnavailable] = useState(false);
|
||||
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
|
||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||
const [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
|
||||
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
|
||||
const detailLoadSequence = useRef(0);
|
||||
|
||||
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
||||
@@ -356,6 +390,29 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
void loadDetails(selected.id);
|
||||
}, [editorMode, selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setInvitationActionClock(new Date());
|
||||
if (selected?.status !== "cancelled" || !selected.cancellation_notice_until) return undefined;
|
||||
const expiresAt = Date.parse(selected.cancellation_notice_until);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return undefined;
|
||||
let timer: number | undefined;
|
||||
const scheduleExpiryRefresh = () => {
|
||||
const remaining = expiresAt - Date.now();
|
||||
if (remaining <= 0) {
|
||||
setInvitationActionClock(new Date());
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(
|
||||
scheduleExpiryRefresh,
|
||||
Math.min(remaining + 25, 2_147_483_647)
|
||||
);
|
||||
};
|
||||
scheduleExpiryRefresh();
|
||||
return () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [selected?.cancellation_notice_until, selected?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected?.id || !selectedParticipant || !canRespond || editorMode || selected.status !== "collecting") {
|
||||
setAvailabilityLoading(false);
|
||||
@@ -645,6 +702,91 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
}
|
||||
}
|
||||
|
||||
async function runParticipantInvitationAction(
|
||||
requestId: string,
|
||||
participant: SchedulingParticipant,
|
||||
action: "copy" | "send" | "revoke"
|
||||
): Promise<boolean> {
|
||||
const request = requests.find((item) => item.id === requestId);
|
||||
if (!request || !(canAdminister || (canWrite && schedulingRequestIsOwned(request, actor)))) return false;
|
||||
if (!participant.revision) {
|
||||
setError(I18N.reloadInvitation);
|
||||
return false;
|
||||
}
|
||||
let succeeded = false;
|
||||
let failureMessage = "";
|
||||
let completionMessage = "";
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
let copied = true;
|
||||
let result: SchedulingInvitationActionResponse;
|
||||
if (action === "copy") {
|
||||
const copyResult = await copySchedulingInvitationLink(
|
||||
issueSchedulingParticipantInvitation(
|
||||
settings,
|
||||
requestId,
|
||||
participant.id,
|
||||
participant.revision,
|
||||
action
|
||||
)
|
||||
);
|
||||
result = copyResult.response;
|
||||
copied = copyResult.copied;
|
||||
} else if (action === "send") {
|
||||
result = await issueSchedulingParticipantInvitation(
|
||||
settings,
|
||||
requestId,
|
||||
participant.id,
|
||||
participant.revision,
|
||||
action
|
||||
);
|
||||
} else {
|
||||
result = await revokeSchedulingParticipantInvitation(
|
||||
settings,
|
||||
requestId,
|
||||
participant.id,
|
||||
participant.revision
|
||||
);
|
||||
}
|
||||
if (action === "copy") {
|
||||
if (!copied) {
|
||||
throw new Error(translateText(I18N.clipboardUnavailable));
|
||||
}
|
||||
completionMessage = I18N.invitationLinkCopied;
|
||||
} else if (action === "send") {
|
||||
if (["failed", "skipped"].includes(result.status)) {
|
||||
throw new Error(translateText(I18N.invitationDeliveryFailed));
|
||||
}
|
||||
completionMessage = I18N.invitationDeliveryRequested;
|
||||
} else {
|
||||
completionMessage = I18N.invitationLinkRevoked;
|
||||
}
|
||||
succeeded = true;
|
||||
} catch (err) {
|
||||
if (isApiError(err, 409)) {
|
||||
failureMessage = I18N.invitationChanged;
|
||||
} else {
|
||||
failureMessage = errorMessage(err, translateText(I18N.requestFailed));
|
||||
}
|
||||
} finally {
|
||||
await loadRequests(requestId);
|
||||
await loadDetails(requestId);
|
||||
setSaving(false);
|
||||
if (failureMessage) setError(failureMessage);
|
||||
if (completionMessage) setSuccess(completionMessage);
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
async function confirmRevokeInvitation() {
|
||||
if (!revokeInvitationTarget) return;
|
||||
const target = revokeInvitationTarget;
|
||||
setRevokeInvitationTarget(null);
|
||||
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
|
||||
}
|
||||
|
||||
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await persistAvailability();
|
||||
@@ -706,9 +848,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
label={I18N.refresh}
|
||||
icon={<RefreshCw aria-hidden="true" size={16} />}
|
||||
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
|
||||
disabled={loading} />
|
||||
disabled={loading || saving} />
|
||||
{canCreateOrWrite ? (
|
||||
<Button type="button" variant="primary" onClick={beginCreate}>
|
||||
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
|
||||
<Plus aria-hidden="true" size={16} /> {I18N.add}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -720,12 +862,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
requests={groups.owned}
|
||||
selectedId={editorMode ? undefined : selected?.id}
|
||||
actor={actor}
|
||||
disabled={saving}
|
||||
onSelect={selectRequest} />
|
||||
<RequestGroup
|
||||
title={I18N.invitedRequests}
|
||||
requests={groups.invited}
|
||||
selectedId={editorMode ? undefined : selected?.id}
|
||||
actor={actor}
|
||||
disabled={saving}
|
||||
onSelect={selectRequest} />
|
||||
{canAdminister && groups.other.length ? (
|
||||
<RequestGroup
|
||||
@@ -733,6 +877,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
requests={groups.other}
|
||||
selectedId={editorMode ? undefined : selected?.id}
|
||||
actor={actor}
|
||||
disabled={saving}
|
||||
onSelect={selectRequest} />
|
||||
) : null}
|
||||
</Card>
|
||||
@@ -1060,7 +1205,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
))}
|
||||
</div>
|
||||
{selected.effective_participant_visibility === "names_and_statuses" ? (
|
||||
<ParticipantsGrid participants={selected.participants} />
|
||||
<ParticipantsGrid
|
||||
request={selected}
|
||||
participants={selected.participants}
|
||||
canManage={canManageSelected}
|
||||
saving={saving}
|
||||
now={invitationActionClock}
|
||||
onCopy={(participant) => void runParticipantInvitationAction(selected.id, participant, "copy")}
|
||||
onSend={(participant) => void runParticipantInvitationAction(selected.id, participant, "send")}
|
||||
onRevoke={(participant) => setRevokeInvitationTarget({
|
||||
requestId: selected.id,
|
||||
participant
|
||||
})} />
|
||||
) : <p className="scheduling-capability-note">{I18N.participantRosterHidden}</p>}
|
||||
</Card>
|
||||
<Card title={I18N.notifications}>
|
||||
@@ -1090,6 +1246,17 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
open={Boolean(revokeInvitationTarget)}
|
||||
title={I18N.revokeInvitationLabel}
|
||||
message={i18nMessage(I18N.revokeInvitationConfirm, {
|
||||
value0: participantDisplayLabel(revokeInvitationTarget?.participant ?? null)
|
||||
})}
|
||||
confirmLabel={I18N.revokeLink}
|
||||
tone="danger"
|
||||
busy={saving}
|
||||
onCancel={() => setRevokeInvitationTarget(null)}
|
||||
onConfirm={() => void confirmRevokeInvitation()} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1177,12 +1344,14 @@ function RequestGroup({
|
||||
requests,
|
||||
selectedId,
|
||||
actor,
|
||||
disabled,
|
||||
onSelect
|
||||
}: {
|
||||
title: string;
|
||||
requests: SchedulingRequest[];
|
||||
selectedId?: string;
|
||||
actor: SchedulingActor;
|
||||
disabled: boolean;
|
||||
onSelect: (requestId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1196,6 +1365,7 @@ function RequestGroup({
|
||||
key={request.id}
|
||||
selected={selectedId === request.id}
|
||||
className="scheduling-list-item"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(request.id)}>
|
||||
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
|
||||
<StatusBadge status={request.status} label={requestStatusLabel(request, actor)} />
|
||||
@@ -1636,7 +1806,25 @@ function CandidateSlotsGrid({
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsGrid({ participants }: { participants: SchedulingParticipant[] }) {
|
||||
function ParticipantsGrid({
|
||||
request,
|
||||
participants,
|
||||
canManage,
|
||||
saving,
|
||||
now,
|
||||
onCopy,
|
||||
onSend,
|
||||
onRevoke
|
||||
}: {
|
||||
request: SchedulingRequest;
|
||||
participants: SchedulingParticipant[];
|
||||
canManage: boolean;
|
||||
saving: boolean;
|
||||
now: Date;
|
||||
onCopy: (participant: SchedulingParticipant) => void;
|
||||
onSend: (participant: SchedulingParticipant) => void;
|
||||
onRevoke: (participant: SchedulingParticipant) => void;
|
||||
}) {
|
||||
const columns: DataGridColumn<SchedulingParticipant>[] = [
|
||||
{ id: "name", header: I18N.name, minWidth: 180, resizable: true, value: (participant) => participant.display_name ?? "" },
|
||||
{ id: "email", header: I18N.participantEmail, minWidth: 220, resizable: true, value: (participant) => participant.email ?? "" },
|
||||
@@ -1646,7 +1834,52 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
|
||||
width: 140,
|
||||
value: (participant) => participantStatusLabel(participant.status),
|
||||
render: (participant) => <StatusBadge status={participant.status} label={participantStatusLabel(participant.status)} />
|
||||
}
|
||||
},
|
||||
...(canManage ? [{
|
||||
id: "actions",
|
||||
header: I18N.actions,
|
||||
width: 132,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (participant) => {
|
||||
const label = participantDisplayLabel(participant);
|
||||
const blocks = schedulingInvitationActionBlocks(request, participant, now);
|
||||
const copyDisabledReason = invitationActionDisabledReason(blocks.copy, request);
|
||||
const deliveryDisabledReason = invitationActionDisabledReason(blocks.send, request);
|
||||
const revokeDisabledReason = invitationActionDisabledReason(blocks.revoke, request);
|
||||
return (
|
||||
<TableActionGroup
|
||||
minimumSlots={3}
|
||||
actions={[
|
||||
{
|
||||
id: "copy-invitation",
|
||||
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
|
||||
icon: <Copy aria-hidden="true" size={16} />,
|
||||
disabled: saving || Boolean(copyDisabledReason),
|
||||
disabledReason: copyDisabledReason,
|
||||
onClick: () => onCopy(participant)
|
||||
},
|
||||
{
|
||||
id: "send-invitation",
|
||||
label: i18nMessage(I18N.sendInvitation, { value0: label }),
|
||||
icon: <Send aria-hidden="true" size={16} />,
|
||||
disabled: saving || Boolean(deliveryDisabledReason),
|
||||
disabledReason: deliveryDisabledReason,
|
||||
onClick: () => onSend(participant)
|
||||
},
|
||||
{
|
||||
id: "revoke-invitation",
|
||||
label: i18nMessage(I18N.revokeInvitation, { value0: label }),
|
||||
icon: <Link2Off aria-hidden="true" size={16} />,
|
||||
variant: "danger",
|
||||
disabled: saving || Boolean(revokeDisabledReason),
|
||||
disabledReason: revokeDisabledReason,
|
||||
onClick: () => onRevoke(participant)
|
||||
}
|
||||
]} />
|
||||
);
|
||||
}
|
||||
} satisfies DataGridColumn<SchedulingParticipant>] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1660,6 +1893,68 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
|
||||
);
|
||||
}
|
||||
|
||||
function invitationActionDisabledReason(
|
||||
block: SchedulingInvitationActionBlock | null,
|
||||
request: SchedulingRequest
|
||||
): string | undefined {
|
||||
if (block === "participation_policy_unavailable") {
|
||||
return request.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable;
|
||||
}
|
||||
if (block === "cancellation_notice_expired") return I18N.cancellationNoticeExpired;
|
||||
if (block === "delivery_unavailable") return I18N.invitationDeliveryUnavailable;
|
||||
if (block === "no_delivery_target") return I18N.noDeliveryTarget;
|
||||
if (block === "no_active_invitation") return I18N.noActiveInvitation;
|
||||
if (block === "participant_revision_unavailable") return I18N.reloadInvitation;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function participantDisplayLabel(participant: SchedulingParticipant | null): string {
|
||||
return participant?.display_name?.trim() || participant?.email?.trim() || I18N.participant;
|
||||
}
|
||||
|
||||
async function copySchedulingInvitationLink(
|
||||
responsePromise: Promise<SchedulingInvitationActionResponse>
|
||||
): Promise<{ response: SchedulingInvitationActionResponse; copied: boolean }> {
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.clipboard?.write &&
|
||||
typeof ClipboardItem !== "undefined"
|
||||
) {
|
||||
try {
|
||||
const content = responsePromise.then((response) => {
|
||||
const value = response.action_url
|
||||
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
|
||||
: null;
|
||||
return new Blob([value ?? ""], { type: "text/plain" });
|
||||
}).catch(() => new Blob([], { type: "text/plain" }));
|
||||
await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]);
|
||||
const response = await responsePromise;
|
||||
const copied = Boolean(
|
||||
response.action_url &&
|
||||
schedulingPublicInvitationUrl(response.action_url, window.location.origin)
|
||||
);
|
||||
return { response, copied };
|
||||
} catch {
|
||||
// The request result is reused below; no second invitation is issued.
|
||||
}
|
||||
}
|
||||
|
||||
const response = await responsePromise;
|
||||
const value = response.action_url
|
||||
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
|
||||
: null;
|
||||
if (!value) return { response, copied: false };
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return { response, copied: true };
|
||||
}
|
||||
} catch {
|
||||
// The caller reports the bounded clipboard failure without exposing the URL.
|
||||
}
|
||||
return { response, copied: false };
|
||||
}
|
||||
|
||||
function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean): SchedulingRequest | null {
|
||||
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user