@rapidmx/web-client 0.2.0
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/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
HiOutlineArrowsPointingIn,
|
|
8
|
+
HiOutlineArrowsPointingOut,
|
|
9
|
+
HiOutlineChevronDown,
|
|
10
|
+
HiOutlineMinus,
|
|
11
|
+
HiOutlinePaperClip,
|
|
12
|
+
HiOutlineTrash,
|
|
13
|
+
HiOutlineXMark,
|
|
14
|
+
} from "react-icons/hi2";
|
|
15
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
16
|
+
import {
|
|
17
|
+
Attachment,
|
|
18
|
+
ComposeRecipientInput,
|
|
19
|
+
Message,
|
|
20
|
+
assembleDraft,
|
|
21
|
+
attachmentContentUrl,
|
|
22
|
+
createDraft,
|
|
23
|
+
listFolders,
|
|
24
|
+
sendMessage,
|
|
25
|
+
setMessageRequestReceipt,
|
|
26
|
+
setMessageScheduledSendTime,
|
|
27
|
+
uploadAttachment,
|
|
28
|
+
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
29
|
+
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
30
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
31
|
+
import type { ComposeSession } from "./ComposeContext.js";
|
|
32
|
+
import RichTextEditor from "./RichTextEditor.js";
|
|
33
|
+
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
34
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
35
|
+
|
|
36
|
+
export interface ComposeWindowProps {
|
|
37
|
+
session: ComposeSession;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
onToggleMinimize: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
43
|
+
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
44
|
+
|
|
45
|
+
const MIN_WIDTH = 320;
|
|
46
|
+
const MIN_HEIGHT = 320;
|
|
47
|
+
// Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
|
|
48
|
+
// handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
|
|
49
|
+
// Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
|
|
50
|
+
// module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
|
|
51
|
+
// during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
|
|
52
|
+
// not just this component's.
|
|
53
|
+
const VIEWPORT_MARGIN = 24;
|
|
54
|
+
|
|
55
|
+
interface Size {
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ResizeEdge = "left" | "top" | "corner";
|
|
61
|
+
|
|
62
|
+
function parseAddresses(value: string): ComposeRecipientInput[] {
|
|
63
|
+
return value
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter((s) => s.length > 0)
|
|
67
|
+
.map((address) => ({ address }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function HeaderButton({ label, onClick, icon: Icon }: { label: string; onClick: () => void; icon: React.ComponentType<{ size?: number }> }) {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
aria-label={label}
|
|
75
|
+
title={label}
|
|
76
|
+
onClick={onClick}
|
|
77
|
+
className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white"
|
|
78
|
+
>
|
|
79
|
+
<Icon size={14} />
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
|
|
86
|
+
* navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
|
|
87
|
+
* form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
|
|
88
|
+
* `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
|
|
89
|
+
* own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
|
|
90
|
+
* context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
|
|
91
|
+
* "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
|
|
92
|
+
*
|
|
93
|
+
* Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
|
|
94
|
+
* room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
|
|
95
|
+
* see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
|
|
96
|
+
* once.
|
|
97
|
+
*/
|
|
98
|
+
export default function ComposeWindow({ session, onClose, onToggleMinimize }: ComposeWindowProps) {
|
|
99
|
+
const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, minimized } = session;
|
|
100
|
+
const isMobile = useIsMobile();
|
|
101
|
+
|
|
102
|
+
const windowRef = useRef<HTMLDivElement>(null);
|
|
103
|
+
const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
|
|
104
|
+
const [folderError, setFolderError] = useState<string | null>(null);
|
|
105
|
+
const [draft, setDraft] = useState<Message | null>(null);
|
|
106
|
+
const [draftError, setDraftError] = useState<string | null>(null);
|
|
107
|
+
const [expanded, setExpanded] = useState(false);
|
|
108
|
+
const [manualSize, setManualSize] = useState<Size | null>(null);
|
|
109
|
+
const [to, setTo] = useState(initialTo ?? "");
|
|
110
|
+
const [cc, setCc] = useState(initialCc ?? "");
|
|
111
|
+
const [bcc, setBcc] = useState("");
|
|
112
|
+
const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
|
|
113
|
+
const [subject, setSubject] = useState(initialSubject ?? "");
|
|
114
|
+
const [html, setHtml] = useState("");
|
|
115
|
+
const [contentReady, setContentReady] = useState(false);
|
|
116
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
117
|
+
const [attachError, setAttachError] = useState<string | null>(null);
|
|
118
|
+
const [sendError, setSendError] = useState<string | null>(null);
|
|
119
|
+
const [sending, setSending] = useState(false);
|
|
120
|
+
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
121
|
+
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
122
|
+
const scheduleButtonRef = useRef<HTMLButtonElement>(null);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
listFolders(mailboxUid)
|
|
126
|
+
.then((folders) => setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid))
|
|
127
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder."));
|
|
128
|
+
}, [mailboxUid]);
|
|
129
|
+
|
|
130
|
+
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
131
|
+
// plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
132
|
+
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
133
|
+
// content and never re-syncs from a later prop change, so this has to resolve before that first
|
|
134
|
+
// mount, not after. A signature-list failure is best-effort, same as every other supplementary,
|
|
135
|
+
// non-blocking fetch in this codebase (e.g. `ConversationThreadPane`'s attachment fetch) — no
|
|
136
|
+
// signature is a completely legitimate outcome, so this falls back to just the quoted content
|
|
137
|
+
// (if any) rather than surfacing an error over what's a cosmetic nicety.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
listMailSignatures(mailboxUid)
|
|
140
|
+
.then((signatures) => {
|
|
141
|
+
const signature = signatures.find((s) =>
|
|
142
|
+
signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
|
|
143
|
+
);
|
|
144
|
+
const signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
|
|
145
|
+
setHtml(`${signatureHtml}${initialQuotedHtml ?? ""}`);
|
|
146
|
+
})
|
|
147
|
+
.catch(() => setHtml(initialQuotedHtml ?? ""))
|
|
148
|
+
.finally(() => setContentReady(true));
|
|
149
|
+
}, [mailboxUid, signatureContext, initialQuotedHtml]);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!draftsFolderUid || draft) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
createDraft(mailboxUid, draftsFolderUid)
|
|
156
|
+
.then(setDraft)
|
|
157
|
+
.catch((err) => setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft."));
|
|
158
|
+
}, [mailboxUid, draftsFolderUid, draft]);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
|
|
162
|
+
* at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
|
|
163
|
+
* always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
|
|
164
|
+
* created per drag gesture (captured over this gesture's own start position/size) rather than
|
|
165
|
+
* stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
|
|
166
|
+
* the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
|
|
167
|
+
* reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
|
|
168
|
+
* listener instead of the current one.
|
|
169
|
+
*/
|
|
170
|
+
function handleResizeStart(edge: ResizeEdge) {
|
|
171
|
+
return (e: React.PointerEvent) => {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
const rect = windowRef.current!.getBoundingClientRect();
|
|
174
|
+
const startX = e.clientX;
|
|
175
|
+
const startY = e.clientY;
|
|
176
|
+
const startWidth = rect.width;
|
|
177
|
+
const startHeight = rect.height;
|
|
178
|
+
|
|
179
|
+
function onMove(ev: PointerEvent) {
|
|
180
|
+
const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
|
|
181
|
+
const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
|
|
182
|
+
const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
|
|
183
|
+
const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
|
|
184
|
+
setManualSize({ width, height });
|
|
185
|
+
}
|
|
186
|
+
function onUp() {
|
|
187
|
+
window.removeEventListener("pointermove", onMove);
|
|
188
|
+
window.removeEventListener("pointerup", onUp);
|
|
189
|
+
}
|
|
190
|
+
window.addEventListener("pointermove", onMove);
|
|
191
|
+
window.addEventListener("pointerup", onUp);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toggleExpanded() {
|
|
196
|
+
setExpanded((v) => !v);
|
|
197
|
+
setManualSize(null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
|
|
201
|
+
* it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
|
|
202
|
+
* that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
|
|
203
|
+
* `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
|
|
204
|
+
* than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
|
|
205
|
+
* toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
|
|
206
|
+
* things load in parallel, so a real (if narrow) window exists where the button is clickable before
|
|
207
|
+
* `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
|
|
208
|
+
*/
|
|
209
|
+
async function handleUploadImage(file: File): Promise<string | null> {
|
|
210
|
+
if (!draft) {
|
|
211
|
+
setAttachError("Please wait for the draft to finish loading before inserting an image.");
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const attachment = await uploadAttachment(draft.uid, file);
|
|
216
|
+
return attachmentContentUrl(attachment.uid);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
|
|
224
|
+
// below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
|
|
225
|
+
// this codebase's established pattern for the same class of "always non-null by the time it's
|
|
226
|
+
// called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
|
|
227
|
+
// below is already a no-op when there's nothing to iterate.
|
|
228
|
+
async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
|
|
229
|
+
const files = Array.from(e.target.files ?? []);
|
|
230
|
+
e.target.value = "";
|
|
231
|
+
setAttachError(null);
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
try {
|
|
234
|
+
const attachment = await uploadAttachment(draft!.uid, file);
|
|
235
|
+
setAttachments((prev) => [...prev, attachment]);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
|
|
243
|
+
// resolves, so this is never reachable with a null `draft`.
|
|
244
|
+
async function handleSend() {
|
|
245
|
+
const toRecipients = parseAddresses(to);
|
|
246
|
+
if (toRecipients.length === 0) {
|
|
247
|
+
setSendError("At least one recipient is required.");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
setSending(true);
|
|
252
|
+
setSendError(null);
|
|
253
|
+
try {
|
|
254
|
+
// See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
|
|
255
|
+
// gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
|
|
256
|
+
// client-side pass is done here either.
|
|
257
|
+
const assembled = await assembleDraft(draft!.uid, {
|
|
258
|
+
to: toRecipients,
|
|
259
|
+
cc: parseAddresses(cc),
|
|
260
|
+
bcc: parseAddresses(bcc),
|
|
261
|
+
subject,
|
|
262
|
+
html,
|
|
263
|
+
});
|
|
264
|
+
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
265
|
+
await sendMessage(withReceipt.uid);
|
|
266
|
+
onClose();
|
|
267
|
+
} catch (err) {
|
|
268
|
+
setSendError(err instanceof ApiRequestError ? err.message : "Could not send this message.");
|
|
269
|
+
} finally {
|
|
270
|
+
setSending(false);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Same reasoning as `handleSend` above (and the "Send later" caret is itself `disabled` alongside
|
|
275
|
+
// Send until `draft` resolves) — never reachable with a null `draft`. Assembles the draft first
|
|
276
|
+
// (same as an immediate send), then sets `scheduledSendTime` on the *freshly assembled* copy before
|
|
277
|
+
// calling `sendMessage()` — `send()` itself is what reads the field and defers relay into Outbox
|
|
278
|
+
// instead of sending immediately (see `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`).
|
|
279
|
+
async function handleScheduleSend(scheduledSendTimeIso: string) {
|
|
280
|
+
const toRecipients = parseAddresses(to);
|
|
281
|
+
setSchedulePickerOpen(false);
|
|
282
|
+
if (toRecipients.length === 0) {
|
|
283
|
+
setSendError("At least one recipient is required.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setSending(true);
|
|
288
|
+
setSendError(null);
|
|
289
|
+
try {
|
|
290
|
+
const assembled = await assembleDraft(draft!.uid, {
|
|
291
|
+
to: toRecipients,
|
|
292
|
+
cc: parseAddresses(cc),
|
|
293
|
+
bcc: parseAddresses(bcc),
|
|
294
|
+
subject,
|
|
295
|
+
html,
|
|
296
|
+
});
|
|
297
|
+
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
298
|
+
const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
|
|
299
|
+
await sendMessage(scheduled.uid);
|
|
300
|
+
onClose();
|
|
301
|
+
} catch (err) {
|
|
302
|
+
setSendError(err instanceof ApiRequestError ? err.message : "Could not schedule this message.");
|
|
303
|
+
} finally {
|
|
304
|
+
setSending(false);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const title = subject.trim() || "New Message";
|
|
309
|
+
const titleId = `compose-title-${id}`;
|
|
310
|
+
|
|
311
|
+
if (minimized) {
|
|
312
|
+
return (
|
|
313
|
+
<div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
|
|
314
|
+
<div
|
|
315
|
+
className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
|
|
316
|
+
onClick={onToggleMinimize}
|
|
317
|
+
>
|
|
318
|
+
<span className="text-sm font-medium truncate">{title}</span>
|
|
319
|
+
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
320
|
+
<HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
|
|
321
|
+
<HeaderButton
|
|
322
|
+
label="Discard draft"
|
|
323
|
+
onClick={onClose}
|
|
324
|
+
icon={HiOutlineXMark}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div
|
|
334
|
+
ref={windowRef}
|
|
335
|
+
role="dialog"
|
|
336
|
+
aria-labelledby={titleId}
|
|
337
|
+
style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
|
|
338
|
+
className={[
|
|
339
|
+
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
340
|
+
isMobile
|
|
341
|
+
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
342
|
+
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
343
|
+
].join(" ")}
|
|
344
|
+
>
|
|
345
|
+
{!isMobile && (
|
|
346
|
+
<>
|
|
347
|
+
<div
|
|
348
|
+
onPointerDown={handleResizeStart("top")}
|
|
349
|
+
aria-hidden="true"
|
|
350
|
+
className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
|
|
351
|
+
/>
|
|
352
|
+
<div
|
|
353
|
+
onPointerDown={handleResizeStart("left")}
|
|
354
|
+
aria-hidden="true"
|
|
355
|
+
className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
|
|
356
|
+
/>
|
|
357
|
+
<div
|
|
358
|
+
onPointerDown={handleResizeStart("corner")}
|
|
359
|
+
role="separator"
|
|
360
|
+
aria-label="Resize"
|
|
361
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
|
|
362
|
+
/>
|
|
363
|
+
</>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
<div
|
|
367
|
+
className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
|
|
368
|
+
onClick={onToggleMinimize}
|
|
369
|
+
>
|
|
370
|
+
<span id={titleId} className="text-sm font-medium truncate">
|
|
371
|
+
{title}
|
|
372
|
+
</span>
|
|
373
|
+
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
374
|
+
<HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
|
|
375
|
+
{!isMobile && (
|
|
376
|
+
<HeaderButton
|
|
377
|
+
label={expanded ? "Collapse" : "Expand"}
|
|
378
|
+
onClick={toggleExpanded}
|
|
379
|
+
icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
382
|
+
<HeaderButton label="Close" onClick={onClose} icon={HiOutlineXMark} />
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
387
|
+
{(folderError || draftError || sendError || attachError) && (
|
|
388
|
+
<div className="px-3 pt-2">
|
|
389
|
+
{folderError && <Alert>{folderError}</Alert>}
|
|
390
|
+
{draftError && <Alert>{draftError}</Alert>}
|
|
391
|
+
{sendError && <Alert>{sendError}</Alert>}
|
|
392
|
+
{attachError && <Alert>{attachError}</Alert>}
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
<div className={FIELD_ROW}>
|
|
397
|
+
<label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
|
|
398
|
+
To
|
|
399
|
+
</label>
|
|
400
|
+
<input
|
|
401
|
+
id={`compose-to-${id}`}
|
|
402
|
+
type="text"
|
|
403
|
+
className={FIELD_INPUT}
|
|
404
|
+
value={to}
|
|
405
|
+
onChange={(e) => setTo(e.target.value)}
|
|
406
|
+
/>
|
|
407
|
+
{!showCcBcc && (
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={() => setShowCcBcc(true)}
|
|
411
|
+
className="text-xs text-text-muted hover:text-text shrink-0"
|
|
412
|
+
>
|
|
413
|
+
Cc Bcc
|
|
414
|
+
</button>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
{showCcBcc && (
|
|
419
|
+
<>
|
|
420
|
+
<div className={FIELD_ROW}>
|
|
421
|
+
<label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
|
|
422
|
+
Cc
|
|
423
|
+
</label>
|
|
424
|
+
<input id={`compose-cc-${id}`} type="text" className={FIELD_INPUT} value={cc} onChange={(e) => setCc(e.target.value)} />
|
|
425
|
+
</div>
|
|
426
|
+
<div className={FIELD_ROW}>
|
|
427
|
+
<label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
|
|
428
|
+
Bcc
|
|
429
|
+
</label>
|
|
430
|
+
<input id={`compose-bcc-${id}`} type="text" className={FIELD_INPUT} value={bcc} onChange={(e) => setBcc(e.target.value)} />
|
|
431
|
+
</div>
|
|
432
|
+
</>
|
|
433
|
+
)}
|
|
434
|
+
|
|
435
|
+
<div className={FIELD_ROW}>
|
|
436
|
+
<input
|
|
437
|
+
aria-label="Subject"
|
|
438
|
+
type="text"
|
|
439
|
+
placeholder="Subject"
|
|
440
|
+
className={FIELD_INPUT}
|
|
441
|
+
value={subject}
|
|
442
|
+
onChange={(e) => setSubject(e.target.value)}
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div className="flex-1 min-h-0 p-2">
|
|
447
|
+
{contentReady && <RichTextEditor value={html} onChange={setHtml} fill onUploadImage={handleUploadImage} />}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{attachments.length > 0 && (
|
|
451
|
+
<ul className="flex flex-wrap gap-2 px-3 pb-2">
|
|
452
|
+
{attachments.map((attachment) => (
|
|
453
|
+
<li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
|
|
454
|
+
{attachment.filename}
|
|
455
|
+
</li>
|
|
456
|
+
))}
|
|
457
|
+
</ul>
|
|
458
|
+
)}
|
|
459
|
+
|
|
460
|
+
<label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
|
|
461
|
+
<input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
|
|
462
|
+
Request a read receipt
|
|
463
|
+
</label>
|
|
464
|
+
|
|
465
|
+
<div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
|
|
466
|
+
<div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={handleSend}
|
|
470
|
+
disabled={!draft || sending}
|
|
471
|
+
className="py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
|
|
472
|
+
>
|
|
473
|
+
{sending ? "Sending…" : "Send"}
|
|
474
|
+
</button>
|
|
475
|
+
<button
|
|
476
|
+
ref={scheduleButtonRef}
|
|
477
|
+
type="button"
|
|
478
|
+
aria-label="Send later"
|
|
479
|
+
aria-haspopup="true"
|
|
480
|
+
aria-expanded={schedulePickerOpen}
|
|
481
|
+
disabled={!draft || sending}
|
|
482
|
+
onClick={() => setSchedulePickerOpen((o) => !o)}
|
|
483
|
+
className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
|
|
484
|
+
>
|
|
485
|
+
<HiOutlineChevronDown size={14} />
|
|
486
|
+
</button>
|
|
487
|
+
</div>
|
|
488
|
+
{schedulePickerOpen && (
|
|
489
|
+
<ScheduleSendPicker
|
|
490
|
+
anchorRef={scheduleButtonRef}
|
|
491
|
+
onClose={() => setSchedulePickerOpen(false)}
|
|
492
|
+
onSchedule={handleScheduleSend}
|
|
493
|
+
/>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
<label
|
|
497
|
+
aria-label="Attach files"
|
|
498
|
+
title="Attach files"
|
|
499
|
+
className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed"
|
|
500
|
+
>
|
|
501
|
+
<HiOutlinePaperClip size={18} />
|
|
502
|
+
<input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
|
|
503
|
+
</label>
|
|
504
|
+
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
aria-label="Discard draft"
|
|
508
|
+
title="Discard draft"
|
|
509
|
+
onClick={onClose}
|
|
510
|
+
className="ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
511
|
+
>
|
|
512
|
+
<HiOutlineTrash size={18} />
|
|
513
|
+
</button>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { RefObject } from "react";
|
|
6
|
+
import { EMOJI_CATEGORIES } from "@rapidmx/react-shared/mail/compose/emojiData.js";
|
|
7
|
+
import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
|
|
8
|
+
|
|
9
|
+
export interface EmojiPickerProps {
|
|
10
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
11
|
+
onSelect: (native: string) => void;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CATEGORY_LABELS: Record<string, string> = {
|
|
16
|
+
people: "Smileys & People",
|
|
17
|
+
nature: "Animals & Nature",
|
|
18
|
+
foods: "Food & Drink",
|
|
19
|
+
activity: "Activity",
|
|
20
|
+
places: "Travel & Places",
|
|
21
|
+
objects: "Objects",
|
|
22
|
+
symbols: "Symbols",
|
|
23
|
+
flags: "Flags",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const WIDTH = 288;
|
|
27
|
+
const HEIGHT = 320;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* "Insert emoji" popup — a scrollable grid of every standard unicode emoji, grouped by category (see
|
|
31
|
+
* `emojiData.ts`). Deliberately no search box, unlike `GifPicker`'s — the user's own request only
|
|
32
|
+
* asked for a browsable grid here, and 8 category headers over ~1900 emojis is already a reasonably
|
|
33
|
+
* navigable amount without one. Rendered via `PopoverPortal` — see that component's own doc comment
|
|
34
|
+
* on why (escaping the toolbar's `overflow-hidden` ancestors).
|
|
35
|
+
*/
|
|
36
|
+
export default function EmojiPicker({ anchorRef, onSelect, onClose }: EmojiPickerProps) {
|
|
37
|
+
return (
|
|
38
|
+
<PopoverPortal anchorRef={anchorRef} onClose={onClose} width={WIDTH} height={HEIGHT} aria-label="Insert emoji">
|
|
39
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
40
|
+
{EMOJI_CATEGORIES.map((category) => (
|
|
41
|
+
<div key={category.id} className="mb-2">
|
|
42
|
+
<h3 className="text-[11px] font-bold uppercase tracking-wide text-text-muted px-1 mb-1 sticky top-0 bg-surface">
|
|
43
|
+
{CATEGORY_LABELS[category.id] ?? category.id}
|
|
44
|
+
</h3>
|
|
45
|
+
<div className="grid grid-cols-8 gap-0.5">
|
|
46
|
+
{category.emojis.map((emoji) => (
|
|
47
|
+
<button
|
|
48
|
+
key={emoji.id}
|
|
49
|
+
type="button"
|
|
50
|
+
title={emoji.name}
|
|
51
|
+
aria-label={emoji.name}
|
|
52
|
+
onClick={() => onSelect(emoji.native)}
|
|
53
|
+
className="w-8 h-8 flex items-center justify-center text-lg rounded-sm hover:bg-surface-alt"
|
|
54
|
+
>
|
|
55
|
+
{emoji.native}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</PopoverPortal>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { RefObject, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { GiphyGif, searchGifs } from "@rapidmx/react-shared/mail/compose/giphyApi.js";
|
|
8
|
+
import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
|
|
9
|
+
|
|
10
|
+
export interface GifPickerProps {
|
|
11
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
12
|
+
onSelect: (url: string) => void;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SEARCH_DEBOUNCE_MS = 350;
|
|
17
|
+
const WIDTH = 288;
|
|
18
|
+
const HEIGHT = 320;
|
|
19
|
+
|
|
20
|
+
/** "Insert GIF" popup — a search box plus a scrollable grid of Giphy thumbnails (trending by default,
|
|
21
|
+
* live-searched as the user types). Selecting one inserts its full-resolution URL into the message
|
|
22
|
+
* body — see `ComposeToolbar.tsx`'s own doc comment on why that's a plain `<img>` reference rather
|
|
23
|
+
* than an uploaded attachment (unlike "Insert image"): a GIF is already hosted at a stable public
|
|
24
|
+
* URL, so there's nothing to upload. Rendered via `PopoverPortal` — see that component's own doc
|
|
25
|
+
* comment on why (escaping the toolbar's `overflow-hidden` ancestors). */
|
|
26
|
+
export default function GifPicker({ anchorRef, onSelect, onClose }: GifPickerProps) {
|
|
27
|
+
const [query, setQuery] = useState("");
|
|
28
|
+
const [gifs, setGifs] = useState<GiphyGif[]>([]);
|
|
29
|
+
const [loading, setLoading] = useState(true);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setLoading(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
const handle = setTimeout(() => {
|
|
36
|
+
searchGifs(query)
|
|
37
|
+
.then(setGifs)
|
|
38
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
|
|
39
|
+
.finally(() => setLoading(false));
|
|
40
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
41
|
+
return () => clearTimeout(handle);
|
|
42
|
+
}, [query]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<PopoverPortal anchorRef={anchorRef} onClose={onClose} width={WIDTH} height={HEIGHT} aria-label="Insert GIF">
|
|
46
|
+
<div className="p-2 border-b border-border shrink-0">
|
|
47
|
+
<input
|
|
48
|
+
type="text"
|
|
49
|
+
autoFocus
|
|
50
|
+
aria-label="Search GIFs"
|
|
51
|
+
placeholder="Search GIFs"
|
|
52
|
+
value={query}
|
|
53
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
54
|
+
className="w-full text-sm py-1.5 px-2 border border-border rounded-sm bg-surface focus:outline-none focus:border-primary"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
58
|
+
{error ? (
|
|
59
|
+
<p className="text-xs text-danger">{error}</p>
|
|
60
|
+
) : loading ? (
|
|
61
|
+
<p className="text-xs text-text-muted">Loading…</p>
|
|
62
|
+
) : gifs.length === 0 ? (
|
|
63
|
+
<p className="text-xs text-text-muted">No GIFs found.</p>
|
|
64
|
+
) : (
|
|
65
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
66
|
+
{gifs.map((gif) => (
|
|
67
|
+
<button
|
|
68
|
+
key={gif.id}
|
|
69
|
+
type="button"
|
|
70
|
+
title={gif.title}
|
|
71
|
+
onClick={() => onSelect(gif.url)}
|
|
72
|
+
className="rounded-sm overflow-hidden hover:opacity-80"
|
|
73
|
+
>
|
|
74
|
+
<img src={gif.previewUrl} alt={gif.title} className="w-full h-20 object-cover" />
|
|
75
|
+
</button>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</PopoverPortal>
|
|
81
|
+
);
|
|
82
|
+
}
|