@rapidmx/web-client 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -862
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -455
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +3 -3
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useId, useState } from "react";
|
|
6
|
+
import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
|
|
7
|
+
import {
|
|
8
|
+
InviteParticipant,
|
|
9
|
+
InviteResponse,
|
|
10
|
+
MessageInvite,
|
|
11
|
+
ProposedTime,
|
|
12
|
+
acceptProposal,
|
|
13
|
+
proposeNewTime,
|
|
14
|
+
removeMessageInvite,
|
|
15
|
+
respondToMessageInvite,
|
|
16
|
+
} from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
17
|
+
import { guestPermissionsOf } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
18
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
19
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
20
|
+
import EventDescriptionView from "../calendar/EventDescriptionView.js";
|
|
21
|
+
import RequestChangeForm from "../calendar/RequestChangeForm.js";
|
|
22
|
+
import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
|
|
23
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
24
|
+
import { calendarHref, conflictSummary, formatInviteWhen } from "./invite/inviteFormat.js";
|
|
25
|
+
import { useMessageInvite } from "./invite/inviteStore.js";
|
|
26
|
+
import ProposeTimeForm from "./invite/ProposeTimeForm.js";
|
|
27
|
+
|
|
28
|
+
export { formatInviteWhen, isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
29
|
+
|
|
30
|
+
const HEADINGS: Record<string, string> = {
|
|
31
|
+
REQUEST: "Meeting invitation",
|
|
32
|
+
CANCEL: "Meeting canceled",
|
|
33
|
+
REPLY: "Meeting response",
|
|
34
|
+
COUNTER: "New time proposed",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The heading of a `COUNTER` that is a guest's request for a change (rather than a different time). */
|
|
38
|
+
const CHANGE_REQUEST_HEADING = "Change requested";
|
|
39
|
+
|
|
40
|
+
/** How an answer reads in "Bob accepted." (a REPLY) - a participant who has not answered has not answered. */
|
|
41
|
+
const REPLY_VERBS: Partial<Record<NonNullable<InviteParticipant["responseStatus"]>, string>> = {
|
|
42
|
+
accepted: "accepted",
|
|
43
|
+
tentative: "tentatively accepted",
|
|
44
|
+
declined: "declined",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The reader's own answer, as the line under the invitation says it. */
|
|
48
|
+
const ANSWER_TEXT: Record<InviteResponse, string> = {
|
|
49
|
+
accepted: "You accepted this meeting.",
|
|
50
|
+
tentative: "You tentatively accepted this meeting.",
|
|
51
|
+
declined: "You declined this meeting. It is not on your calendar.",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ANSWER_CLASS: Record<InviteResponse, string> = {
|
|
55
|
+
accepted: "text-success",
|
|
56
|
+
tentative: "text-text",
|
|
57
|
+
declined: "text-danger",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const RESPONSES: { response: InviteResponse; label: string; failure: string }[] = [
|
|
61
|
+
{ response: "accepted", label: "Accept", failure: "Couldn't accept this meeting" },
|
|
62
|
+
{ response: "tentative", label: "Tentative", failure: "Couldn't respond tentatively to this meeting" },
|
|
63
|
+
{ response: "declined", label: "Decline", failure: "Couldn't decline this meeting" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** The most attendees that are listed by name; a longer list is only counted. */
|
|
67
|
+
const MAX_NAMED_ATTENDEES = 5;
|
|
68
|
+
|
|
69
|
+
/** What the card is doing for the reader right now: an answer being sent, an add/remove, a proposal, an accepted proposal. */
|
|
70
|
+
type Pending = InviteResponse | "add" | "remove" | "propose" | "acceptProposal";
|
|
71
|
+
|
|
72
|
+
function attendeeSummary(attendees: InviteParticipant[]): string | undefined {
|
|
73
|
+
if (attendees.length === 0) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
|
|
77
|
+
return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface InviteCardProps {
|
|
81
|
+
/** The message whose calendar invitation to show, if it carries one. Nothing is drawn until the server says there is one. */
|
|
82
|
+
messageUid: string;
|
|
83
|
+
/** The level of the card's heading, so it sits below whatever heading its surroundings have (`2` in the reading pane, `3` inside a thread's card). */
|
|
84
|
+
headingLevel?: 2 | 3;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The calendar invitation a message carries, as Outlook and Gmail draw it: what the meeting is, when and where, who organizes it, and the buttons
|
|
89
|
+
* that answer it - Accept, Tentative, Decline (and Propose new time) for a request; "Add to calendar" for a published event; "Remove from
|
|
90
|
+
* calendar" for a cancellation; "Accept proposal" for an attendee's proposed new time. An attendee's answer to the reader's own invitation is
|
|
91
|
+
* one line ("Bob tentatively accepted."). Everything that follows an action (the meeting going onto the calendar or off it, the reply to the
|
|
92
|
+
* organizer) is the server's; this only asks for it and draws the invitation the server sends back.
|
|
93
|
+
*
|
|
94
|
+
* Asks the server once per message (through the shared invitation cache, so the list row's RSVP chip and this card agree). A message with no readable
|
|
95
|
+
* invitation, and a lookup that fails for any reason, draw nothing - a lookup that fails is never retried and never disturbs the message around it.
|
|
96
|
+
* An answer that arrives after the message changed or this was unmounted is dropped.
|
|
97
|
+
*/
|
|
98
|
+
export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardProps) {
|
|
99
|
+
const { invite, setInvite } = useMessageInvite(messageUid);
|
|
100
|
+
const [pending, setPending] = useState<Pending | null>(null);
|
|
101
|
+
const [proposing, setProposing] = useState(false);
|
|
102
|
+
// Said once after a proposal went out: the invitation itself doesn't change.
|
|
103
|
+
const [proposed, setProposed] = useState(false);
|
|
104
|
+
// A guest's request for a change (or for guests to be added): the form is open, and once it went out.
|
|
105
|
+
const [requesting, setRequesting] = useState(false);
|
|
106
|
+
const [requested, setRequested] = useState(false);
|
|
107
|
+
const headingId = useId();
|
|
108
|
+
|
|
109
|
+
if (!invite) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Runs one of the card's actions: the buttons are off while it does, its result replaces what the card shows, and a failure leaves the card as it was. */
|
|
114
|
+
async function run(action: Pending, failure: string, call: () => Promise<MessageInvite>): Promise<boolean> {
|
|
115
|
+
setPending(action);
|
|
116
|
+
try {
|
|
117
|
+
setInvite(await call());
|
|
118
|
+
return true;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
notifyApiError(err, failure);
|
|
121
|
+
return false;
|
|
122
|
+
} finally {
|
|
123
|
+
setPending(null);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function sendProposal(proposal: ProposedTime) {
|
|
128
|
+
if (await run("propose", "Couldn't send the proposed time", () => proposeNewTime(messageUid, proposal))) {
|
|
129
|
+
setProposing(false);
|
|
130
|
+
setProposed(true);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const busy = pending !== null;
|
|
135
|
+
const Heading = `h${headingLevel}` as const;
|
|
136
|
+
const method = invite.method.toUpperCase();
|
|
137
|
+
const isReply = method === "REPLY";
|
|
138
|
+
const isCounter = method === "COUNTER";
|
|
139
|
+
// A `COUNTER` a guest sent to change the event (not just its time): whether the organizer's server applied it already.
|
|
140
|
+
const changeRequest = isCounter ? invite.changeRequest : undefined;
|
|
141
|
+
const permissions = guestPermissionsOf(invite.guestPermissions ?? {});
|
|
142
|
+
const hiddenList = !invite.isOrganizer && !permissions.guestsCanSeeGuestList;
|
|
143
|
+
const canRequest = !!invite.calendarEventUid && (!!invite.canRequestChange || !!invite.canRequestInvite);
|
|
144
|
+
const title = invite.summary?.trim() || "(no title)";
|
|
145
|
+
const when = formatInviteWhen(invite);
|
|
146
|
+
const attendees = hiddenList ? "The organizer has hidden the guest list" : attendeeSummary(invite.attendees);
|
|
147
|
+
const hasDescription = !!(invite.descriptionHtml?.trim() || invite.description?.trim());
|
|
148
|
+
const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
|
|
149
|
+
const sender = invite.reply ?? invite.attendees[0];
|
|
150
|
+
const senderName = sender ? sender.displayName || sender.address : "Someone";
|
|
151
|
+
const showResponses = invite.canRespond && !invite.isOrganizer;
|
|
152
|
+
const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
|
|
153
|
+
const hasActions =
|
|
154
|
+
showResponses ||
|
|
155
|
+
invite.canPropose ||
|
|
156
|
+
invite.canAcceptProposal ||
|
|
157
|
+
invite.canAdd ||
|
|
158
|
+
invite.canRemove ||
|
|
159
|
+
canRequest ||
|
|
160
|
+
(invite.onCalendar && !!invite.calendarEventUid);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<section aria-labelledby={headingId} className="rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text">
|
|
164
|
+
<Heading id={headingId} className="flex items-center gap-2 text-sm font-semibold">
|
|
165
|
+
<HiOutlineCalendarDays size={16} aria-hidden="true" className="shrink-0" />
|
|
166
|
+
{changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")}
|
|
167
|
+
</Heading>
|
|
168
|
+
{isReply && (
|
|
169
|
+
// Someone answering the reader's own invitation: a line of news, nothing to do.
|
|
170
|
+
<p className="break-words">
|
|
171
|
+
<span className="font-semibold">{senderName}</span> {(sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded"}.
|
|
172
|
+
</p>
|
|
173
|
+
)}
|
|
174
|
+
{isCounter && !changeRequest && (
|
|
175
|
+
<p className="break-words">
|
|
176
|
+
<span className="font-semibold">{senderName}</span> proposed a new time{when ? <>: {when}</> : "."}
|
|
177
|
+
</p>
|
|
178
|
+
)}
|
|
179
|
+
{changeRequest && (
|
|
180
|
+
<p className="break-words">
|
|
181
|
+
{changeRequest.applied ? (
|
|
182
|
+
<>
|
|
183
|
+
<span className="font-semibold">{senderName}</span>’s change was applied.
|
|
184
|
+
</>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
<span className="font-semibold">{senderName}</span> requested a change.
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
</p>
|
|
191
|
+
)}
|
|
192
|
+
<p className="font-medium break-words">{title}</p>
|
|
193
|
+
{!isReply && (
|
|
194
|
+
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
|
|
195
|
+
{when && (!isCounter || !!changeRequest) && (
|
|
196
|
+
<>
|
|
197
|
+
<dt className="text-text-muted">When</dt>
|
|
198
|
+
<dd className="break-words">
|
|
199
|
+
{when}
|
|
200
|
+
{invite.recurring && <span className="text-text-muted"> (Repeats)</span>}
|
|
201
|
+
</dd>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
{invite.location && (
|
|
205
|
+
<>
|
|
206
|
+
<dt className="text-text-muted">Where</dt>
|
|
207
|
+
<dd className="break-words">{invite.location}</dd>
|
|
208
|
+
</>
|
|
209
|
+
)}
|
|
210
|
+
{hasDescription && (!isCounter || !!changeRequest) && (
|
|
211
|
+
<>
|
|
212
|
+
<dt className="text-text-muted">Description</dt>
|
|
213
|
+
<dd className="break-words">
|
|
214
|
+
<EventDescriptionView html={invite.descriptionHtml} text={invite.description} />
|
|
215
|
+
</dd>
|
|
216
|
+
</>
|
|
217
|
+
)}
|
|
218
|
+
{invite.organizer && !isCounter && (
|
|
219
|
+
<>
|
|
220
|
+
<dt className="text-text-muted">Organizer</dt>
|
|
221
|
+
<dd className="break-words">{formatMailAddress(invite.organizer)}</dd>
|
|
222
|
+
</>
|
|
223
|
+
)}
|
|
224
|
+
{attendees && (!isCounter || !!changeRequest) && (
|
|
225
|
+
<>
|
|
226
|
+
<dt className="text-text-muted">Attendees</dt>
|
|
227
|
+
<dd className="break-words">{attendees}</dd>
|
|
228
|
+
</>
|
|
229
|
+
)}
|
|
230
|
+
{visibility && !isCounter && (
|
|
231
|
+
<>
|
|
232
|
+
<dt className="text-text-muted">Visibility</dt>
|
|
233
|
+
<dd className="break-words">{visibility}</dd>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
</dl>
|
|
237
|
+
)}
|
|
238
|
+
{isReply && when && <p className="text-text-muted break-words">{when}</p>}
|
|
239
|
+
{conflicts && (
|
|
240
|
+
<p className="text-danger break-words">
|
|
241
|
+
<span className="font-medium">Conflicts with:</span> {conflicts}
|
|
242
|
+
</p>
|
|
243
|
+
)}
|
|
244
|
+
{invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (
|
|
245
|
+
<p className="text-xs text-text-muted break-words">{describeGuestPermissions(permissions)}</p>
|
|
246
|
+
)}
|
|
247
|
+
{invite.isOrganizer && !isReply && !isCounter && <p className="text-text-muted">You are the organizer of this meeting.</p>}
|
|
248
|
+
{method === "CANCEL" && <p className="font-medium text-danger">This meeting was canceled.</p>}
|
|
249
|
+
{invite.response && !invite.isOrganizer && !isReply && !isCounter && (
|
|
250
|
+
<p role="status" className={`font-medium ${ANSWER_CLASS[invite.response]}`}>
|
|
251
|
+
{ANSWER_TEXT[invite.response]}
|
|
252
|
+
</p>
|
|
253
|
+
)}
|
|
254
|
+
{isCounter && invite.response === "accepted" && !changeRequest?.applied && (
|
|
255
|
+
<p role="status" className="font-medium text-success">
|
|
256
|
+
You accepted the proposed time.
|
|
257
|
+
</p>
|
|
258
|
+
)}
|
|
259
|
+
{proposed && (
|
|
260
|
+
<p role="status" className="font-medium text-text">
|
|
261
|
+
Your proposed time was sent to the organizer.
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
264
|
+
{requested && (
|
|
265
|
+
<p role="status" className="font-medium text-text">
|
|
266
|
+
Your change was sent to the organizer.
|
|
267
|
+
</p>
|
|
268
|
+
)}
|
|
269
|
+
{invite.outdated && <p className="text-xs text-text-muted">A newer version of this meeting is already on your calendar.</p>}
|
|
270
|
+
{hasActions && (
|
|
271
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
272
|
+
{showResponses && (
|
|
273
|
+
<div role="group" aria-label="Respond to this meeting" aria-busy={busy || undefined} className="flex flex-wrap gap-2">
|
|
274
|
+
{RESPONSES.map(({ response, label, failure }) => {
|
|
275
|
+
const current = invite.response === response;
|
|
276
|
+
return (
|
|
277
|
+
<Button
|
|
278
|
+
key={response}
|
|
279
|
+
type="button"
|
|
280
|
+
variant="secondary"
|
|
281
|
+
className={["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" ")}
|
|
282
|
+
aria-pressed={current}
|
|
283
|
+
aria-busy={pending === response || undefined}
|
|
284
|
+
loading={pending === response}
|
|
285
|
+
disabled={busy || current}
|
|
286
|
+
onClick={() => void run(response, failure, () => respondToMessageInvite(messageUid, response))}
|
|
287
|
+
>
|
|
288
|
+
{current && <HiOutlineCheck size={14} aria-hidden="true" />}
|
|
289
|
+
{label}
|
|
290
|
+
</Button>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
{invite.canPropose && !proposing && (
|
|
296
|
+
<Button type="button" variant="text" disabled={busy} onClick={() => setProposing(true)}>
|
|
297
|
+
Propose new time
|
|
298
|
+
</Button>
|
|
299
|
+
)}
|
|
300
|
+
{canRequest && !requesting && (
|
|
301
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={() => setRequesting(true)}>
|
|
302
|
+
{invite.canRequestChange ? "Request a change" : "Add guests"}
|
|
303
|
+
</Button>
|
|
304
|
+
)}
|
|
305
|
+
{invite.canAcceptProposal && invite.response !== "accepted" && (
|
|
306
|
+
<Button
|
|
307
|
+
type="button"
|
|
308
|
+
variant="secondary"
|
|
309
|
+
className="!w-auto"
|
|
310
|
+
aria-busy={pending === "acceptProposal" || undefined}
|
|
311
|
+
loading={pending === "acceptProposal"}
|
|
312
|
+
disabled={busy}
|
|
313
|
+
onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid))}
|
|
314
|
+
>
|
|
315
|
+
Accept proposal
|
|
316
|
+
</Button>
|
|
317
|
+
)}
|
|
318
|
+
{invite.canAdd && (
|
|
319
|
+
<Button
|
|
320
|
+
type="button"
|
|
321
|
+
variant="secondary"
|
|
322
|
+
className="!w-auto"
|
|
323
|
+
aria-busy={pending === "add" || undefined}
|
|
324
|
+
loading={pending === "add"}
|
|
325
|
+
disabled={busy}
|
|
326
|
+
// A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
|
|
327
|
+
onClick={() => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted"))}
|
|
328
|
+
>
|
|
329
|
+
Add to calendar
|
|
330
|
+
</Button>
|
|
331
|
+
)}
|
|
332
|
+
{invite.canRemove && (
|
|
333
|
+
<Button
|
|
334
|
+
type="button"
|
|
335
|
+
variant="secondary"
|
|
336
|
+
className="!w-auto"
|
|
337
|
+
aria-busy={pending === "remove" || undefined}
|
|
338
|
+
loading={pending === "remove"}
|
|
339
|
+
disabled={busy}
|
|
340
|
+
onClick={() => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid))}
|
|
341
|
+
>
|
|
342
|
+
Remove from calendar
|
|
343
|
+
</Button>
|
|
344
|
+
)}
|
|
345
|
+
{invite.onCalendar && invite.calendarEventUid && (
|
|
346
|
+
<a href={calendarHref(invite)} className="text-sm font-medium text-primary-dark hover:underline">
|
|
347
|
+
Open in Calendar
|
|
348
|
+
</a>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
{requesting && (
|
|
353
|
+
<div className="rounded-md bg-surface p-3">
|
|
354
|
+
<RequestChangeForm
|
|
355
|
+
subject={{
|
|
356
|
+
uid: invite.calendarEventUid as string,
|
|
357
|
+
title: invite.summary ?? "",
|
|
358
|
+
location: invite.location,
|
|
359
|
+
startDate: invite.startDate,
|
|
360
|
+
endDate: invite.endDate,
|
|
361
|
+
allDay: invite.allDay,
|
|
362
|
+
recurring: invite.recurring,
|
|
363
|
+
description: invite.description,
|
|
364
|
+
descriptionHtml: invite.descriptionHtml,
|
|
365
|
+
guestAddresses: invite.attendees.map((attendee) => attendee.address),
|
|
366
|
+
}}
|
|
367
|
+
canChange={!!invite.canRequestChange}
|
|
368
|
+
canInvite={!!invite.canRequestInvite}
|
|
369
|
+
onSent={() => {
|
|
370
|
+
setRequesting(false);
|
|
371
|
+
setRequested(true);
|
|
372
|
+
}}
|
|
373
|
+
onCancel={() => setRequesting(false)}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
{proposing && (
|
|
378
|
+
<ProposeTimeForm
|
|
379
|
+
invite={invite}
|
|
380
|
+
busy={pending === "propose"}
|
|
381
|
+
onSubmit={(proposal) => void sendProposal(proposal)}
|
|
382
|
+
onCancel={() => setProposing(false)}
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
385
|
+
</section>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -43,6 +43,9 @@ import {
|
|
|
43
43
|
replySubject,
|
|
44
44
|
} from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
45
45
|
import MoveToFolderDialog from "./MoveToFolderDialog.js";
|
|
46
|
+
import InviteCard from "./InviteCard.js";
|
|
47
|
+
import { isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
48
|
+
import { useMessageInvite } from "./invite/inviteStore.js";
|
|
46
49
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
47
50
|
import type { MessageSecurityResult, SignatureFailureReason } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
48
51
|
import { extractAddresses, type MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
|
|
@@ -992,6 +995,14 @@ function MessageDetailContent({
|
|
|
992
995
|
const forwarded = message.flags.forwarded === true;
|
|
993
996
|
const cardUnread = threadHeader?.unread ?? false;
|
|
994
997
|
const showFooter = footer ?? !inThread;
|
|
998
|
+
// Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
|
|
999
|
+
// server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
|
|
1000
|
+
// that are being written or sent, not received invitations.
|
|
1001
|
+
const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
|
|
1002
|
+
// The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
|
|
1003
|
+
// it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
|
|
1004
|
+
const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
|
|
1005
|
+
const listedAttachments = shownInvite ? attachments.filter((attachment) => !isCalendarAttachment(attachment)) : attachments;
|
|
995
1006
|
|
|
996
1007
|
const senderRecipient = { displayName: senderName, address: senderAddress };
|
|
997
1008
|
const sendingStatus = (
|
|
@@ -1348,6 +1359,7 @@ function MessageDetailContent({
|
|
|
1348
1359
|
</div>
|
|
1349
1360
|
))}
|
|
1350
1361
|
{receiptError && <Alert>{receiptError}</Alert>}
|
|
1362
|
+
{mayHoldInvite && <InviteCard messageUid={message.uid} headingLevel={inThread ? 3 : 2} />}
|
|
1351
1363
|
{security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (
|
|
1352
1364
|
<p role="status" className="py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
1353
1365
|
These attachments come from a message whose signature couldn’t be verified. Open them only if
|
|
@@ -1370,9 +1382,9 @@ function MessageDetailContent({
|
|
|
1370
1382
|
))}
|
|
1371
1383
|
</ul>
|
|
1372
1384
|
)
|
|
1373
|
-
:
|
|
1385
|
+
: listedAttachments.length > 0 && (
|
|
1374
1386
|
<ul className="flex flex-wrap gap-2">
|
|
1375
|
-
{
|
|
1387
|
+
{listedAttachments.map((attachment) => (
|
|
1376
1388
|
<li key={attachment.uid}>
|
|
1377
1389
|
<a
|
|
1378
1390
|
href={attachmentContentUrl(attachment.uid)}
|
|
@@ -1421,6 +1433,16 @@ function MessageDetailContent({
|
|
|
1421
1433
|
>
|
|
1422
1434
|
Reply
|
|
1423
1435
|
</Button>
|
|
1436
|
+
<Button
|
|
1437
|
+
type="button"
|
|
1438
|
+
variant="secondary"
|
|
1439
|
+
className="!w-auto"
|
|
1440
|
+
aria-label="Reply all to this message"
|
|
1441
|
+
disabled={preparingCompose}
|
|
1442
|
+
onClick={() => void handleReplyOrForward("replyAll")}
|
|
1443
|
+
>
|
|
1444
|
+
Reply All
|
|
1445
|
+
</Button>
|
|
1424
1446
|
<Button
|
|
1425
1447
|
type="button"
|
|
1426
1448
|
variant="secondary"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { HTMLAttributes, ReactNode, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { HiOutlineArchiveBox, HiOutlineFolder } from "react-icons/hi2";
|
|
7
|
+
import { useSwipe } from "../../gestures/useSwipe.js";
|
|
8
|
+
|
|
9
|
+
export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
|
|
10
|
+
/** The element the row is: a list item, or the row's own `div` inside one. */
|
|
11
|
+
as?: "li" | "div";
|
|
12
|
+
/** Swipes are only followed while this is `true`: the phone layout, and not while rows are being selected. */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
/** Right to left. Resolves whether it went through; `false` brings the row back, anything else leaves it gone (its list removes it). */
|
|
15
|
+
onArchive: () => Promise<boolean>;
|
|
16
|
+
/** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
|
|
17
|
+
onMove: () => void;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How long the row takes to leave, or come back - `motion-reduce` skips it. */
|
|
22
|
+
const SLIDE = "transform 150ms ease-out";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A row of a list of mail that slides with a finger: right to left it takes the row away to Archive (a green panel with the
|
|
26
|
+
* archive icon follows the row in), left to right it comes back and asks for a folder to move it to (a blue panel with a
|
|
27
|
+
* folder). The panels are children of the row placed just outside its edges, so they travel with it and fill the gap it leaves
|
|
28
|
+
* without the row's own markup, or the list's, changing; the list has to clip them (`overflow-x-clip`).
|
|
29
|
+
*
|
|
30
|
+
* Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
|
|
31
|
+
* to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
|
|
32
|
+
*/
|
|
33
|
+
export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps) {
|
|
34
|
+
const [offset, setOffset] = useState(0);
|
|
35
|
+
const [width, setWidth] = useState(0);
|
|
36
|
+
/** The row is on its way out (or waiting for the server to confirm it), so a finger can't drag it. */
|
|
37
|
+
const [leaving, setLeaving] = useState(false);
|
|
38
|
+
const [dragging, setDragging] = useState(false);
|
|
39
|
+
const element = useRef<HTMLElement | null>(null);
|
|
40
|
+
const mounted = useRef(true);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
mounted.current = true;
|
|
43
|
+
return () => {
|
|
44
|
+
mounted.current = false;
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const swipe = useSwipe({
|
|
49
|
+
enabled: enabled && !leaving,
|
|
50
|
+
onDrag: (next) => {
|
|
51
|
+
setDragging(next !== 0);
|
|
52
|
+
setOffset(next);
|
|
53
|
+
},
|
|
54
|
+
onSwipe: (direction) => {
|
|
55
|
+
if (direction === "right") {
|
|
56
|
+
onMove();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// A swipe is only ever heard by the row's own element, which is what `element` holds.
|
|
60
|
+
const rowWidth = element.current!.clientWidth;
|
|
61
|
+
setWidth(rowWidth);
|
|
62
|
+
setLeaving(true);
|
|
63
|
+
setOffset(-rowWidth);
|
|
64
|
+
void onArchive().then((archived) => {
|
|
65
|
+
// Unmounted on success, once the list has dropped the row; a refusal brings it back.
|
|
66
|
+
if (!archived && mounted.current) {
|
|
67
|
+
setLeaving(false);
|
|
68
|
+
setOffset(0);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!enabled) {
|
|
75
|
+
return (
|
|
76
|
+
<Tag className={className} style={style} {...rest}>
|
|
77
|
+
{children}
|
|
78
|
+
</Tag>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const active = offset !== 0;
|
|
82
|
+
return (
|
|
83
|
+
<Tag
|
|
84
|
+
{...rest}
|
|
85
|
+
{...swipe.handlers}
|
|
86
|
+
ref={(node: HTMLElement | null) => {
|
|
87
|
+
element.current = node;
|
|
88
|
+
}}
|
|
89
|
+
className={className}
|
|
90
|
+
style={{
|
|
91
|
+
...style,
|
|
92
|
+
...swipe.style,
|
|
93
|
+
transform: active ? `translateX(${offset}px)` : undefined,
|
|
94
|
+
transition: dragging ? "none" : SLIDE,
|
|
95
|
+
}}
|
|
96
|
+
data-swiping={active ? "true" : undefined}
|
|
97
|
+
>
|
|
98
|
+
{children}
|
|
99
|
+
{(active || leaving) && (
|
|
100
|
+
<>
|
|
101
|
+
<span
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
data-swipe-panel="move"
|
|
104
|
+
className="absolute inset-y-0 right-full w-full bg-primary text-white flex items-center justify-end pr-6"
|
|
105
|
+
>
|
|
106
|
+
<HiOutlineFolder size={24} />
|
|
107
|
+
</span>
|
|
108
|
+
<span
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
data-swipe-panel="archive"
|
|
111
|
+
style={width ? { width } : undefined}
|
|
112
|
+
className="absolute inset-y-0 left-full w-full bg-success text-white flex items-center justify-start pl-6"
|
|
113
|
+
>
|
|
114
|
+
<HiOutlineArchiveBox size={24} />
|
|
115
|
+
</span>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</Tag>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -1342,10 +1342,12 @@ export default function ComposeWindow({
|
|
|
1342
1342
|
aria-labelledby={titleId}
|
|
1343
1343
|
style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
|
|
1344
1344
|
className={[
|
|
1345
|
-
"
|
|
1345
|
+
"shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1346
|
+
// `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
|
|
1347
|
+
// sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
|
|
1346
1348
|
isMobile
|
|
1347
1349
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1348
|
-
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1350
|
+
: ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1349
1351
|
].join(" ")}
|
|
1350
1352
|
>
|
|
1351
1353
|
{!isMobile && (
|
|
@@ -1421,7 +1423,9 @@ export default function ComposeWindow({
|
|
|
1421
1423
|
<select
|
|
1422
1424
|
id={`compose-from-${id}`}
|
|
1423
1425
|
className={`${FIELD_INPUT} disabled:opacity-55`}
|
|
1424
|
-
|
|
1426
|
+
// Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
|
|
1427
|
+
// (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
|
|
1428
|
+
value={mailboxUid}
|
|
1425
1429
|
disabled={hasUploads}
|
|
1426
1430
|
title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
|
|
1427
1431
|
onChange={(e) => handleFromChange(e.target.value)}
|