@rightkit/workspace-ui 0.0.0-stage → 0.1.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/CHANGELOG.md +5 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +35 -2
- package/dist/env.d.ts +59 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/lib/vcard.d.ts +12 -0
- package/dist/lib/vcard.js +86 -0
- package/dist/views/CalendarEventFormModal.d.ts +10 -0
- package/dist/views/CalendarEventFormModal.js +19 -0
- package/dist/views/CalendarEventPopover.d.ts +10 -0
- package/dist/views/CalendarEventPopover.js +9 -0
- package/dist/views/CalendarGrids.d.ts +16 -0
- package/dist/views/CalendarGrids.js +106 -0
- package/dist/views/CalendarView.d.ts +3 -0
- package/dist/views/CalendarView.js +333 -0
- package/dist/views/ContactsView.d.ts +3 -0
- package/dist/views/ContactsView.js +230 -0
- package/dist/views/ContactsView.types.d.ts +8 -0
- package/dist/views/ContactsView.types.js +7 -0
- package/dist/views/DriveFileMenu.d.ts +14 -0
- package/dist/views/DriveFileMenu.js +32 -0
- package/dist/views/DrivePreviewModal.d.ts +9 -0
- package/dist/views/DrivePreviewModal.js +11 -0
- package/dist/views/DriveView.d.ts +3 -0
- package/dist/views/DriveView.js +186 -0
- package/dist/views/MeetView.d.ts +1 -0
- package/dist/views/MeetView.js +81 -0
- package/dist/views/TasksView.d.ts +3 -0
- package/dist/views/TasksView.js +156 -0
- package/dist/views/driveFileGlyph.d.ts +6 -0
- package/dist/views/driveFileGlyph.js +19 -0
- package/package.json +56 -3
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, Dialog, SkeletonRows, Input } from "@rightkit/react-shell/react";
|
|
3
|
+
// Contacts — a browsable list (parity with contacts.google.com): search +
|
|
4
|
+
// full contact list with avatars, a create/edit modal, delete, and the
|
|
5
|
+
// import/export/dedupe tools. Group filtering reads the active group from the
|
|
6
|
+
// store (set in ContactsNav). Directory search falls back to the People API for
|
|
7
|
+
// addresses outside the personal contact list.
|
|
8
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import Icon from "@rightkit/mail-ui/components/Icons";
|
|
10
|
+
import { AccountSwitcher } from "@rightkit/mail-ui/components/AccountSwitcher";
|
|
11
|
+
import { colorFor, initialOf } from "@rightkit/mail-ui/lib/mail";
|
|
12
|
+
import { buildVcf, parseVcf, parseContactsCsv } from "../lib/vcard.js";
|
|
13
|
+
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
14
|
+
import { showDialog } from "@rightkit/mail-ui/hooks/dialog";
|
|
15
|
+
import { searchRecipients, listAllContacts, createContact, updateContact, deleteContact, saveTextDownload, } from "@rightkit/mail-ui/lib/ipc";
|
|
16
|
+
import { EMPTY_EDITOR } from "./ContactsView.types.js";
|
|
17
|
+
function ContactsViewInner() {
|
|
18
|
+
const openComposeTo = useAppStore((s) => s.openComposeTo);
|
|
19
|
+
const activeAccount = useAppStore((s) => s.activeAccount);
|
|
20
|
+
const setErr = useAppStore((s) => s.setErr);
|
|
21
|
+
const showUndo = useAppStore((s) => s.showUndo);
|
|
22
|
+
const setView = useAppStore((s) => s.setView);
|
|
23
|
+
const contactsQuery = useAppStore((s) => s.contactsQuery);
|
|
24
|
+
const setContactsQuery = useAppStore((s) => s.setContactsQuery);
|
|
25
|
+
const contactsBusy = useAppStore((s) => s.contactsBusy);
|
|
26
|
+
const setContactsBusy = useAppStore((s) => s.setContactsBusy);
|
|
27
|
+
const contactDupes = useAppStore((s) => s.contactDupes);
|
|
28
|
+
const setContactDupes = useAppStore((s) => s.setContactDupes);
|
|
29
|
+
const [contacts, setContacts] = useState([]);
|
|
30
|
+
const [dirResults, setDirResults] = useState([]);
|
|
31
|
+
const [editor, setEditor] = useState(null);
|
|
32
|
+
const fileRef = useRef(null);
|
|
33
|
+
const searchTimer = useRef(undefined);
|
|
34
|
+
function loadAll() {
|
|
35
|
+
if (!activeAccount)
|
|
36
|
+
return;
|
|
37
|
+
setContactsBusy(true);
|
|
38
|
+
listAllContacts(activeAccount)
|
|
39
|
+
.then((cs) => setContacts(cs.sort((a, b) => (a.display_name || a.emails[0] || "").localeCompare(b.display_name || b.emails[0] || ""))))
|
|
40
|
+
.catch((e) => setErr(String(e)))
|
|
41
|
+
.finally(() => setContactsBusy(false));
|
|
42
|
+
}
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
loadAll();
|
|
45
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadAll is a plain function redefined every render (closes over setErr/setContactsBusy); tracking it would refetch the full contact list on every render instead of only on account change.
|
|
46
|
+
}, [activeAccount]);
|
|
47
|
+
// Local filter is instant; the directory lookup (≥2 chars) is debounced so we
|
|
48
|
+
// don't fire an IPC call on every keystroke.
|
|
49
|
+
function onSearch(q) {
|
|
50
|
+
setContactsQuery(q);
|
|
51
|
+
clearTimeout(searchTimer.current);
|
|
52
|
+
if (!activeAccount || q.trim().length < 2) {
|
|
53
|
+
setDirResults([]);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
searchTimer.current = setTimeout(() => {
|
|
57
|
+
searchRecipients(activeAccount, q.trim())
|
|
58
|
+
.then((rs) => setDirResults(rs))
|
|
59
|
+
.catch(() => setDirResults([]));
|
|
60
|
+
}, 280);
|
|
61
|
+
}
|
|
62
|
+
const query = contactsQuery.trim().toLowerCase();
|
|
63
|
+
const filtered = useMemo(() => {
|
|
64
|
+
if (!query)
|
|
65
|
+
return contacts;
|
|
66
|
+
return contacts.filter((c) => c.display_name.toLowerCase().includes(query) ||
|
|
67
|
+
c.emails.some((e) => e.toLowerCase().includes(query)) ||
|
|
68
|
+
c.org.toLowerCase().includes(query));
|
|
69
|
+
}, [contacts, query]);
|
|
70
|
+
// Directory hits not already in the personal list (only while searching).
|
|
71
|
+
const extraDir = useMemo(() => {
|
|
72
|
+
if (query.length < 2)
|
|
73
|
+
return [];
|
|
74
|
+
const known = new Set(contacts.flatMap((c) => c.emails.map((e) => e.toLowerCase())));
|
|
75
|
+
return dirResults.filter((r) => r.email && !known.has(r.email.toLowerCase()));
|
|
76
|
+
}, [dirResults, contacts, query]);
|
|
77
|
+
function emailContact(addr) {
|
|
78
|
+
setView("inbox");
|
|
79
|
+
setTimeout(() => openComposeTo(addr, "", ""), 0);
|
|
80
|
+
}
|
|
81
|
+
function startCreate() {
|
|
82
|
+
setEditor(EMPTY_EDITOR);
|
|
83
|
+
}
|
|
84
|
+
function startEdit(c) {
|
|
85
|
+
setEditor({
|
|
86
|
+
resource_name: c.resource_name,
|
|
87
|
+
etag: c.etag,
|
|
88
|
+
name: c.display_name,
|
|
89
|
+
emails: c.emails.length ? c.emails : [""],
|
|
90
|
+
phones: c.phones.length ? c.phones : [""],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function saveEditor() {
|
|
94
|
+
if (!activeAccount || !editor)
|
|
95
|
+
return;
|
|
96
|
+
const name = editor.name.trim();
|
|
97
|
+
const emails = editor.emails.map((e) => e.trim()).filter(Boolean);
|
|
98
|
+
const phones = editor.phones.map((p) => p.trim()).filter(Boolean);
|
|
99
|
+
if (!name && emails.length === 0) {
|
|
100
|
+
setErr("A contact needs at least a name or an email.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setContactsBusy(true);
|
|
104
|
+
const op = editor.resource_name
|
|
105
|
+
? updateContact(activeAccount, editor.resource_name, editor.etag, name, emails, phones)
|
|
106
|
+
: createContact(activeAccount, name, emails, phones);
|
|
107
|
+
op.then(() => {
|
|
108
|
+
showUndo(editor.resource_name ? "Contact saved" : "Contact created", [], "");
|
|
109
|
+
setEditor(null);
|
|
110
|
+
loadAll();
|
|
111
|
+
})
|
|
112
|
+
.catch((e) => setErr(String(e)))
|
|
113
|
+
.finally(() => setContactsBusy(false));
|
|
114
|
+
}
|
|
115
|
+
async function removeContact(c) {
|
|
116
|
+
if (!activeAccount)
|
|
117
|
+
return;
|
|
118
|
+
if ((await showDialog({ kind: "confirm", message: `Delete "${c.display_name || c.emails[0]}"? Moves to Google's contact trash (30-day undo).` })) !== "ok")
|
|
119
|
+
return;
|
|
120
|
+
setContactsBusy(true);
|
|
121
|
+
deleteContact(activeAccount, c.resource_name)
|
|
122
|
+
.then(() => {
|
|
123
|
+
showUndo("Contact deleted", [], "");
|
|
124
|
+
setContacts((prev) => prev.filter((x) => x.resource_name !== c.resource_name));
|
|
125
|
+
})
|
|
126
|
+
.catch((e) => setErr(String(e)))
|
|
127
|
+
.finally(() => setContactsBusy(false));
|
|
128
|
+
}
|
|
129
|
+
function exportContacts() {
|
|
130
|
+
if (!activeAccount || contactsBusy)
|
|
131
|
+
return;
|
|
132
|
+
const vcf = buildVcf(contacts.map((c) => ({ name: c.display_name, emails: c.emails, phones: c.phones, org: c.org })));
|
|
133
|
+
// Saved natively to Downloads (exclusive atomic publish), like every other download.
|
|
134
|
+
saveTextDownload("contacts.vcf", vcf)
|
|
135
|
+
.then(() => showUndo(`Exported ${contacts.length} contacts`, [], ""))
|
|
136
|
+
.catch((e) => setErr(String(e)));
|
|
137
|
+
}
|
|
138
|
+
function importContactsFile(file) {
|
|
139
|
+
if (!activeAccount)
|
|
140
|
+
return;
|
|
141
|
+
file
|
|
142
|
+
.text()
|
|
143
|
+
.then(async (text) => {
|
|
144
|
+
const parsed = file.name.toLowerCase().endsWith(".csv")
|
|
145
|
+
? parseContactsCsv(text)
|
|
146
|
+
: parseVcf(text);
|
|
147
|
+
if (parsed.length === 0) {
|
|
148
|
+
setErr("No contacts found in that file.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if ((await showDialog({ kind: "confirm", message: `Import ${parsed.length} contact(s) into ${activeAccount}?` })) !== "ok")
|
|
152
|
+
return;
|
|
153
|
+
setContactsBusy(true);
|
|
154
|
+
let ok = 0;
|
|
155
|
+
for (const c of parsed) {
|
|
156
|
+
try {
|
|
157
|
+
await createContact(activeAccount, c.name, c.emails, c.phones);
|
|
158
|
+
ok++;
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
setErr(String(e));
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
showUndo(`Imported ${ok} of ${parsed.length} contacts`, [], "");
|
|
166
|
+
loadAll();
|
|
167
|
+
})
|
|
168
|
+
.catch((e) => setErr(String(e)))
|
|
169
|
+
.finally(() => setContactsBusy(false));
|
|
170
|
+
}
|
|
171
|
+
function findContactDupes() {
|
|
172
|
+
if (!activeAccount || contactsBusy)
|
|
173
|
+
return;
|
|
174
|
+
const byEmail = new Map();
|
|
175
|
+
for (const c of contacts) {
|
|
176
|
+
for (const e of c.emails) {
|
|
177
|
+
const key = e.trim().toLowerCase();
|
|
178
|
+
if (!key)
|
|
179
|
+
continue;
|
|
180
|
+
const arr = byEmail.get(key) ?? [];
|
|
181
|
+
if (!arr.includes(c))
|
|
182
|
+
arr.push(c);
|
|
183
|
+
byEmail.set(key, arr);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const groups = [];
|
|
188
|
+
for (const arr of byEmail.values()) {
|
|
189
|
+
if (arr.length < 2)
|
|
190
|
+
continue;
|
|
191
|
+
const key = arr.map((c) => c.resource_name).sort().join("|");
|
|
192
|
+
if (seen.has(key))
|
|
193
|
+
continue;
|
|
194
|
+
seen.add(key);
|
|
195
|
+
groups.push({ primary: arr[0], dups: arr.slice(1) });
|
|
196
|
+
}
|
|
197
|
+
setContactDupes(groups);
|
|
198
|
+
if (groups.length === 0)
|
|
199
|
+
showUndo("No duplicate contacts found", [], "");
|
|
200
|
+
}
|
|
201
|
+
async function mergeDupeGroup(group) {
|
|
202
|
+
if (!activeAccount || contactsBusy)
|
|
203
|
+
return;
|
|
204
|
+
if ((await showDialog({ kind: "confirm", message: `Merge ${group.dups.length} duplicate(s) into "${group.primary.display_name || group.primary.emails[0]}"?` })) !== "ok")
|
|
205
|
+
return;
|
|
206
|
+
const all = [group.primary, ...group.dups];
|
|
207
|
+
const emails = Array.from(new Set(all.flatMap((c) => c.emails.map((e) => e.trim())).filter(Boolean)));
|
|
208
|
+
const phones = Array.from(new Set(all.flatMap((c) => c.phones.map((p) => p.trim())).filter(Boolean)));
|
|
209
|
+
const name = all.map((c) => c.display_name).find((n) => n.trim()) || emails[0] || "";
|
|
210
|
+
setContactsBusy(true);
|
|
211
|
+
updateContact(activeAccount, group.primary.resource_name, group.primary.etag, name, emails, phones)
|
|
212
|
+
.then(async () => {
|
|
213
|
+
for (const d of group.dups)
|
|
214
|
+
await deleteContact(activeAccount, d.resource_name);
|
|
215
|
+
showUndo("Merged duplicates", [], "");
|
|
216
|
+
setContactDupes((prev) => (prev ? prev.filter((g) => g !== group) : prev));
|
|
217
|
+
loadAll();
|
|
218
|
+
})
|
|
219
|
+
.catch((e) => setErr(String(e)))
|
|
220
|
+
.finally(() => setContactsBusy(false));
|
|
221
|
+
}
|
|
222
|
+
const setEd = (patch) => setEditor((e) => (e ? { ...e, ...patch } : e));
|
|
223
|
+
return (_jsxs("div", { className: "contactsview", children: [_jsxs("div", { className: "contacts-bar", children: [_jsx("h2", { className: "workspace-title", children: "Contacts" }), _jsx("span", { className: "contacts-count", children: contacts.length }), _jsx("div", { className: "drive-bar-spacer" }), _jsx(Input, { className: "workspace-search", placeholder: "Search contacts & directory\u2026", value: contactsQuery, onChange: (e) => onSearch(e.target.value) }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: exportContacts, children: "Export" }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: () => fileRef.current?.click(), children: "Import" }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: findContactDupes, children: "Find duplicates" }), _jsxs(Button, { variant: "primary", size: "sm", onClick: startCreate, children: [_jsx(Icon, { name: "person-add", size: 15 }), " Create"] }), _jsx(AccountSwitcher, {}), _jsx(Input, { ref: fileRef, type: "file", accept: ".vcf,.csv", style: { display: "none" }, onChange: (e) => {
|
|
224
|
+
const f = e.target.files?.[0];
|
|
225
|
+
if (f)
|
|
226
|
+
importContactsFile(f);
|
|
227
|
+
e.target.value = "";
|
|
228
|
+
} })] }), _jsxs("div", { className: "contacts-scroll", children: [contactDupes && (_jsxs("div", { className: "dupe-panel", children: [_jsxs("div", { className: "labels-head", children: [_jsx("span", { children: "Duplicates" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setContactDupes(null), children: "Dismiss" })] }), contactDupes.length === 0 ? (_jsx("p", { className: "hint", children: "No duplicate contacts." })) : (contactDupes.map((g) => (_jsxs("div", { className: "contact-row", children: [_jsx("span", { className: "contact-av", style: { background: colorFor(g.primary.emails[0] || g.primary.display_name) }, children: initialOf(g.primary.display_name || g.primary.emails[0]) }), _jsx("span", { className: "contact-name", children: g.primary.display_name || g.primary.emails[0] }), _jsxs("span", { className: "contact-sub", children: [g.dups.length + 1, " copies \u00B7 ", g.primary.emails[0]] }), _jsx(Button, { variant: "ghost", size: "sm", disabled: contactsBusy, onClick: () => mergeDupeGroup(g), children: "Merge" })] }, g.primary.resource_name))))] })), contactsBusy && contacts.length === 0 ? (_jsx(SkeletonRows, { rows: 10 })) : filtered.length === 0 && extraDir.length === 0 ? (_jsxs("div", { className: "workspace-empty", children: [_jsx(Icon, { name: "people", size: 42 }), _jsx("p", { className: "hint", children: query ? "No matching contacts." : "No contacts yet." }), !query && _jsx(Button, { variant: "primary", size: "sm", onClick: startCreate, children: "Create your first contact" })] })) : (_jsxs("div", { className: "contacts-list", children: [filtered.map((c) => (_jsxs("div", { className: "contact-row", children: [_jsx("span", { className: "contact-av", style: { background: colorFor(c.emails[0] || c.display_name) }, children: initialOf(c.display_name || c.emails[0]) }), _jsx("span", { className: "contact-name", children: c.display_name || "(no name)" }), _jsx("span", { className: "contact-email", children: c.emails[0] || "" }), _jsx("span", { className: "contact-sub", children: c.phones[0] || c.org || "" }), _jsxs("div", { className: "contact-actions", children: [c.emails[0] && (_jsx(Button, { variant: "ghost", size: "sm", title: "Email", onClick: () => emailContact(c.emails[0]), children: "Email" })), _jsx(Button, { variant: "ghost", size: "sm", title: "Edit", onClick: () => startEdit(c), children: "Edit" }), _jsx(Button, { variant: "danger", size: "sm", title: "Delete", onClick: () => removeContact(c), children: "Delete" })] })] }, c.resource_name))), extraDir.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "labels-head", children: _jsx("span", { children: "From the directory" }) }), extraDir.map((r) => (_jsxs("div", { className: "contact-row", children: [r.photo_url ? (_jsx("img", { className: "contact-av", src: r.photo_url, alt: "" })) : (_jsx("span", { className: "contact-av", style: { background: colorFor(r.email) }, children: initialOf(r.name || r.email) })), _jsx("span", { className: "contact-name", children: r.name || r.email }), _jsx("span", { className: "contact-email", children: r.email }), _jsx("span", { className: "contact-sub" }), _jsxs("div", { className: "contact-actions", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => emailContact(r.email), children: "Email" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setEditor({ resource_name: "", etag: "", name: r.name || "", emails: [r.email], phones: [""] }), children: "Add to contacts" })] })] }, r.email)))] }))] }))] }), editor && (_jsxs(Dialog, { title: editor.resource_name ? "Edit contact" : "New contact", width: 440, onClose: () => setEditor(null), children: [_jsx(Input, { className: "event-title-input", placeholder: "Name", autoFocus: true, value: editor.name, onChange: (e) => setEd({ name: e.target.value }) }), _jsxs("div", { className: "ce-field-group", children: [_jsx("span", { className: "ce-label", children: "Email" }), editor.emails.map((em, i) => (_jsx(Input, { className: "workspace-search ce-input", type: "email", placeholder: "name@example.com", value: em, onChange: (e) => setEd({ emails: editor.emails.map((v, j) => (j === i ? e.target.value : v)) }) }, i))), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setEd({ emails: [...editor.emails, ""] }), children: "+ Add email" })] }), _jsxs("div", { className: "ce-field-group", children: [_jsx("span", { className: "ce-label", children: "Phone" }), editor.phones.map((ph, i) => (_jsx(Input, { className: "workspace-search ce-input", placeholder: "+1 555 000 0000", value: ph, onChange: (e) => setEd({ phones: editor.phones.map((v, j) => (j === i ? e.target.value : v)) }) }, i))), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setEd({ phones: [...editor.phones, ""] }), children: "+ Add phone" })] }), _jsxs("div", { className: "ws-form-foot", children: [_jsx(Button, { variant: "primary", disabled: contactsBusy, onClick: saveEditor, children: editor.resource_name ? "Save" : "Create" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setEditor(null), children: "Cancel" })] })] }))] }));
|
|
229
|
+
}
|
|
230
|
+
export const ContactsView = memo(ContactsViewInner);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useDrive } from "@rightkit/mail-ui/hooks/useDrive";
|
|
2
|
+
import { type DriveFile } from "@rightkit/mail-ui/lib/ipc";
|
|
3
|
+
type DriveFileMenuProps = {
|
|
4
|
+
f: DriveFile;
|
|
5
|
+
anchor: Element;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
onPreview: () => void;
|
|
8
|
+
drive: ReturnType<typeof useDrive>;
|
|
9
|
+
onMove: (f: DriveFile) => void;
|
|
10
|
+
onShare: (f: DriveFile) => void;
|
|
11
|
+
};
|
|
12
|
+
/** The per-file dropdown (preview/open/download/attach/rename/move/share/trash). */
|
|
13
|
+
export declare function DriveFileMenu({ f, anchor, onClose, onPreview, drive, onMove, onShare }: DriveFileMenuProps): import("react").JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { ContextMenu } from "@rightkit/react-shell/react";
|
|
4
|
+
import { openExternal } from "@rightkit/mail-ui/lib/ipc";
|
|
5
|
+
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
6
|
+
/** The per-file dropdown (preview/open/download/attach/rename/move/share/trash). */
|
|
7
|
+
export function DriveFileMenu({ f, anchor, onClose, onPreview, drive, onMove, onShare }) {
|
|
8
|
+
const setErr = useAppStore((s) => s.setErr);
|
|
9
|
+
const items = useMemo(() => {
|
|
10
|
+
const list = [];
|
|
11
|
+
if (!f.is_folder)
|
|
12
|
+
list.push({ id: "preview", label: "Preview", run: onPreview });
|
|
13
|
+
if (f.web_view_link) {
|
|
14
|
+
list.push({
|
|
15
|
+
id: "open",
|
|
16
|
+
label: "Open in Drive",
|
|
17
|
+
run: () => { openExternal(f.web_view_link).catch((e) => setErr(String(e))); },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (!f.is_folder)
|
|
21
|
+
list.push({ id: "download", label: "Download", run: () => drive.download(f) });
|
|
22
|
+
if (!f.is_folder && f.web_view_link) {
|
|
23
|
+
list.push({ id: "link", label: "Insert link in email", run: () => drive.insertDriveLink(f) });
|
|
24
|
+
}
|
|
25
|
+
list.push({ id: "rename", label: "Rename", separatorBefore: list.length > 0, run: () => drive.rename(f) });
|
|
26
|
+
list.push({ id: "move", label: "Move", run: () => onMove(f) });
|
|
27
|
+
list.push({ id: "share", label: "Share", run: () => onShare(f) });
|
|
28
|
+
list.push({ id: "trash", label: "Move to trash", danger: true, run: () => drive.trash(f) });
|
|
29
|
+
return list;
|
|
30
|
+
}, [f, drive, onPreview, onMove, onShare, setErr]);
|
|
31
|
+
return (_jsx("span", { style: { display: "contents" }, onClick: (e) => e.stopPropagation(), children: _jsx(ContextMenu, { anchor: anchor, placement: "bottom-end", items: items, onClose: onClose, label: "File actions", showLead: false }) }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useDrive } from "@rightkit/mail-ui/hooks/useDrive";
|
|
2
|
+
import { type DriveFile } from "@rightkit/mail-ui/lib/ipc";
|
|
3
|
+
type DrivePreviewModalProps = {
|
|
4
|
+
drivePreview: DriveFile;
|
|
5
|
+
setDrivePreview: (f: DriveFile | null) => void;
|
|
6
|
+
drive: ReturnType<typeof useDrive>;
|
|
7
|
+
};
|
|
8
|
+
export declare function DrivePreviewModal({ drivePreview, setDrivePreview, drive }: DrivePreviewModalProps): import("react").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, Dialog } from "@rightkit/react-shell/react";
|
|
3
|
+
import Icon from "@rightkit/mail-ui/components/Icons";
|
|
4
|
+
import { openExternal } from "@rightkit/mail-ui/lib/ipc";
|
|
5
|
+
import { formatBytes } from "@rightkit/settings-ui/core";
|
|
6
|
+
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
7
|
+
import { fileGlyph } from "./driveFileGlyph.js";
|
|
8
|
+
export function DrivePreviewModal({ drivePreview, setDrivePreview, drive }) {
|
|
9
|
+
const setErr = useAppStore((s) => s.setErr);
|
|
10
|
+
return (_jsxs(Dialog, { title: drivePreview.name, width: 520, onClose: () => setDrivePreview(null), children: [drivePreview.thumbnail_link ? (_jsx("img", { className: "dp-thumb", src: drivePreview.thumbnail_link, alt: "", onError: (e) => { e.currentTarget.style.display = "none"; } })) : (_jsxs("div", { className: "dp-noimg", children: [_jsx(Icon, { name: fileGlyph(drivePreview).icon, size: 48 }), _jsx("span", { className: "hint", children: "No preview available" })] })), _jsxs("div", { className: "dp-meta hint", children: [drivePreview.mime_type, drivePreview.size ? ` · ${formatBytes(Number(drivePreview.size))}` : "", drivePreview.modified_time ? ` · ${new Date(drivePreview.modified_time).toLocaleString()}` : ""] }), _jsxs("div", { className: "dp-actions", children: [drivePreview.web_view_link && (_jsx(Button, { variant: "primary", onClick: () => openExternal(drivePreview.web_view_link).catch((e) => setErr(String(e))), children: "Open in Drive" })), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => drive.download(drivePreview), children: "Download" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => { drive.insertDriveLink(drivePreview); setDrivePreview(null); }, children: "Insert link in email" })] })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Drive — the file grid. Mirrors drive.google.com: a breadcrumb/title header
|
|
3
|
+
// with type filter + search + grid/list toggle, then a grid (or list) of file
|
|
4
|
+
// cards with thumbnails or colored type tiles, each with a hover ⋮ menu.
|
|
5
|
+
// Shelf nav + storage live in DriveNav; shared Drive logic in useDrive.
|
|
6
|
+
import { memo, useEffect, useState } from "react";
|
|
7
|
+
import Icon from "@rightkit/mail-ui/components/Icons";
|
|
8
|
+
import { AccountSwitcher } from "@rightkit/mail-ui/components/AccountSwitcher";
|
|
9
|
+
import { Button, Dialog, IconButton, Skeleton, SegmentedControl, Input, Select } from "@rightkit/react-shell/react";
|
|
10
|
+
import { setPref } from "@rightkit/mail-ui/lib/preferences";
|
|
11
|
+
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
12
|
+
import { useDrive, FOLDER_MIME } from "@rightkit/mail-ui/hooks/useDrive";
|
|
13
|
+
import { DriveFileMenu } from "./DriveFileMenu.js";
|
|
14
|
+
import { DrivePreviewModal } from "./DrivePreviewModal.js";
|
|
15
|
+
import { fileGlyph } from "./driveFileGlyph.js";
|
|
16
|
+
import { listDriveFiles, driveStorageQuota, driveMove, driveShare, driveListPermissions, driveRevokePermission, } from "@rightkit/mail-ui/lib/ipc";
|
|
17
|
+
/** Native button that carries the keyboard/AT semantics of a file card or row;
|
|
18
|
+
* the mouse click is handled by the card itself. */
|
|
19
|
+
const FILE_OPEN_STYLE = {
|
|
20
|
+
display: "flex",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
gap: 8,
|
|
23
|
+
flex: "1 1 auto",
|
|
24
|
+
minWidth: 0,
|
|
25
|
+
padding: 0,
|
|
26
|
+
border: 0,
|
|
27
|
+
background: "none",
|
|
28
|
+
color: "inherit",
|
|
29
|
+
font: "inherit",
|
|
30
|
+
textAlign: "left",
|
|
31
|
+
cursor: "pointer",
|
|
32
|
+
};
|
|
33
|
+
const SHELF_LABEL = {
|
|
34
|
+
recent: "Recent",
|
|
35
|
+
shared: "Shared with me",
|
|
36
|
+
starred: "Starred",
|
|
37
|
+
trash: "Trash",
|
|
38
|
+
};
|
|
39
|
+
function DriveViewInner() {
|
|
40
|
+
const drive = useDrive();
|
|
41
|
+
const setErr = useAppStore((s) => s.setErr);
|
|
42
|
+
const showUndo = useAppStore((s) => s.showUndo);
|
|
43
|
+
const setDrivePath = useAppStore((s) => s.setDrivePath);
|
|
44
|
+
const setDriveQuery = useAppStore((s) => s.setDriveQuery);
|
|
45
|
+
const driveFiles = useAppStore((s) => s.driveFiles);
|
|
46
|
+
const setDriveFiles = useAppStore((s) => s.setDriveFiles);
|
|
47
|
+
const driveToken = useAppStore((s) => s.driveToken);
|
|
48
|
+
const setDriveToken = useAppStore((s) => s.setDriveToken);
|
|
49
|
+
const drivePreview = useAppStore((s) => s.drivePreview);
|
|
50
|
+
const setDrivePreview = useAppStore((s) => s.setDrivePreview);
|
|
51
|
+
const driveViewMode = useAppStore((s) => s.driveViewMode);
|
|
52
|
+
const setDriveViewMode = useAppStore((s) => s.setDriveViewMode);
|
|
53
|
+
const setDriveQuota = useAppStore((s) => s.setDriveQuota);
|
|
54
|
+
const driveBusy = useAppStore((s) => s.driveBusy);
|
|
55
|
+
const { activeAccount, driveShelf, drivePath, driveQuery, driveType } = drive;
|
|
56
|
+
// Transient UI state: the open ⋮ menu, the move/share modals, drag-over state.
|
|
57
|
+
const [menuFor, setMenuFor] = useState(null);
|
|
58
|
+
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
59
|
+
const openMenuFor = (id, anchor) => { setMenuAnchor(anchor); setMenuFor(id); };
|
|
60
|
+
const [dragOver, setDragOver] = useState(false);
|
|
61
|
+
const [moveFile, setMoveFile] = useState(null);
|
|
62
|
+
const [moveFolders, setMoveFolders] = useState([]);
|
|
63
|
+
const [shareFile, setShareFile] = useState(null);
|
|
64
|
+
const [perms, setPerms] = useState([]);
|
|
65
|
+
const [shareEmail, setShareEmail] = useState("");
|
|
66
|
+
const [shareRole, setShareRole] = useState("reader");
|
|
67
|
+
// Load My Drive root + storage quota on mount / account change.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setDrivePath([]);
|
|
70
|
+
setDriveQuery("");
|
|
71
|
+
drive.reload(undefined, "");
|
|
72
|
+
if (activeAccount)
|
|
73
|
+
driveStorageQuota(activeAccount).then(setDriveQuota).catch(() => { });
|
|
74
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- setDrivePath/setDriveQuery/drive.reload/setDriveQuota are store setters or a method on the `drive` object recreated every render; tracking them would reload Drive's root on every render instead of only on account change.
|
|
75
|
+
}, [activeAccount]);
|
|
76
|
+
function openMoveDialog(f) {
|
|
77
|
+
if (!activeAccount)
|
|
78
|
+
return;
|
|
79
|
+
setMenuFor(null);
|
|
80
|
+
setMoveFile(f);
|
|
81
|
+
setMoveFolders([]);
|
|
82
|
+
listDriveFiles(activeAccount, undefined, undefined, undefined, undefined, FOLDER_MIME)
|
|
83
|
+
.then((l) => setMoveFolders(l.files.filter((x) => x.id !== f.id)))
|
|
84
|
+
.catch((e) => setErr(String(e)));
|
|
85
|
+
}
|
|
86
|
+
function doMove(targetId, targetName) {
|
|
87
|
+
if (!activeAccount || !moveFile)
|
|
88
|
+
return;
|
|
89
|
+
const oldParent = driveShelf === "my" ? drive.currentFolderId() ?? "root" : undefined;
|
|
90
|
+
driveMove(activeAccount, moveFile.id, targetId, oldParent)
|
|
91
|
+
.then(() => {
|
|
92
|
+
showUndo(`Moved to ${targetName}`, [], "");
|
|
93
|
+
setMoveFile(null);
|
|
94
|
+
drive.refresh();
|
|
95
|
+
})
|
|
96
|
+
.catch((e) => setErr(String(e)));
|
|
97
|
+
}
|
|
98
|
+
function openShareDialog(f) {
|
|
99
|
+
if (!activeAccount)
|
|
100
|
+
return;
|
|
101
|
+
setMenuFor(null);
|
|
102
|
+
setShareFile(f);
|
|
103
|
+
setPerms([]);
|
|
104
|
+
setShareEmail("");
|
|
105
|
+
setShareRole("reader");
|
|
106
|
+
driveListPermissions(activeAccount, f.id).then(setPerms).catch((e) => setErr(String(e)));
|
|
107
|
+
}
|
|
108
|
+
function addShare() {
|
|
109
|
+
if (!activeAccount || !shareFile || !shareEmail.trim())
|
|
110
|
+
return;
|
|
111
|
+
driveShare(activeAccount, shareFile.id, shareEmail.trim(), shareRole)
|
|
112
|
+
.then(() => {
|
|
113
|
+
showUndo(`Shared with ${shareEmail.trim()} (${shareRole})`, [], "");
|
|
114
|
+
setShareEmail("");
|
|
115
|
+
driveListPermissions(activeAccount, shareFile.id).then(setPerms).catch(() => { });
|
|
116
|
+
})
|
|
117
|
+
.catch((e) => setErr(String(e)));
|
|
118
|
+
}
|
|
119
|
+
function revokeShare(permId) {
|
|
120
|
+
if (!activeAccount || !shareFile)
|
|
121
|
+
return;
|
|
122
|
+
driveRevokePermission(activeAccount, shareFile.id, permId)
|
|
123
|
+
.then(() => {
|
|
124
|
+
setPerms((prev) => prev.filter((p) => p.id !== permId));
|
|
125
|
+
showUndo("Access removed", [], "");
|
|
126
|
+
})
|
|
127
|
+
.catch((e) => setErr(String(e)));
|
|
128
|
+
}
|
|
129
|
+
function openItem(f) {
|
|
130
|
+
if (f.is_folder)
|
|
131
|
+
drive.openFolder(f);
|
|
132
|
+
else
|
|
133
|
+
setDrivePreview(f);
|
|
134
|
+
}
|
|
135
|
+
const title = driveShelf === "my" ? null : SHELF_LABEL[driveShelf];
|
|
136
|
+
return (_jsxs("div", { className: "driveview", onClick: () => setMenuFor(null), children: [_jsxs("div", { className: "drive-bar", children: [title ? (_jsx("h2", { className: "drive-title", children: title })) : (_jsxs("div", { className: "drive-crumbs", children: [_jsx(Button, { variant: "ghost", className: "crumb", onClick: () => drive.breadcrumbTo(-1), children: "My Drive" }), drivePath.map((p, i) => (_jsxs("span", { className: "crumb-wrap", children: [_jsx(Icon, { name: "chevron-right", size: 14, className: "crumb-sep" }), _jsx(Button, { variant: "ghost", className: "crumb", onClick: () => drive.breadcrumbTo(i), children: p.name })] }, p.id)))] })), _jsx("div", { className: "drive-bar-spacer" }), _jsx(Input, { className: "drive-search", placeholder: "Search in Drive\u2026", value: driveQuery, onChange: (e) => drive.setDriveQuery(e.target.value), onKeyDown: (e) => {
|
|
137
|
+
if (e.key === "Enter")
|
|
138
|
+
drive.reload(undefined, driveQuery);
|
|
139
|
+
} }), _jsx(Select, { className: "drive-typefilter", value: driveType, label: "Filter by type", options: [
|
|
140
|
+
{ value: "", label: "All types" },
|
|
141
|
+
{ value: FOLDER_MIME, label: "Folders" },
|
|
142
|
+
{ value: "document", label: "Docs" },
|
|
143
|
+
{ value: "spreadsheet", label: "Sheets" },
|
|
144
|
+
{ value: "presentation", label: "Slides" },
|
|
145
|
+
{ value: "application/pdf", label: "PDFs" },
|
|
146
|
+
{ value: "image/", label: "Images" },
|
|
147
|
+
{ value: "video/", label: "Video" },
|
|
148
|
+
], onChange: drive.setTypeFilter }), _jsx("div", { className: "drive-toggle", children: _jsx(SegmentedControl, { label: "Drive view", value: driveViewMode, options: [
|
|
149
|
+
{ value: "list", label: _jsx(Icon, { name: "list", size: 18 }), title: "List view" },
|
|
150
|
+
{ value: "grid", label: _jsx(Icon, { name: "grid", size: 18 }), title: "Grid view" },
|
|
151
|
+
], onChange: (m) => { setDriveViewMode(m); setPref("driveViewMode", m); } }) }), _jsx(AccountSwitcher, {})] }), _jsxs("div", { className: `drive-scroll ${dragOver ? "drag-over" : ""}`, onDragOver: (e) => { e.preventDefault(); if (!dragOver)
|
|
152
|
+
setDragOver(true); }, onDragLeave: (e) => { if (e.currentTarget === e.target)
|
|
153
|
+
setDragOver(false); }, onDrop: (e) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
setDragOver(false);
|
|
156
|
+
if (e.dataTransfer.files?.length)
|
|
157
|
+
drive.upload(e.dataTransfer.files);
|
|
158
|
+
}, children: [dragOver && (_jsxs("div", { className: "drive-dropzone", children: [_jsx(Icon, { name: "upload", size: 40 }), _jsxs("span", { children: ["Drop files to upload to ", driveShelf === "my" && drivePath.length ? drivePath[drivePath.length - 1].name : "My Drive"] })] })), driveBusy ? (driveViewMode === "grid" ? (_jsx("div", { className: "drive-grid", children: Array.from({ length: 8 }).map((_, i) => (_jsx(Skeleton, { height: 150, radius: 12 }, i))) })) : (_jsx("div", { className: "drive-list", children: Array.from({ length: 8 }).map((_, i) => (_jsx("div", { className: "file-row", children: _jsx(Skeleton, { height: 18 }) }, i))) }))) : driveFiles.length === 0 ? (_jsxs("div", { className: "drive-empty", children: [_jsx(Icon, { name: "drive", size: 44 }), _jsx("p", { className: "hint", children: driveShelf === "trash" ? "Trash is empty." : driveQuery ? "No matching files." : "Nothing here yet." })] })) : driveViewMode === "grid" ? (_jsx("div", { className: "drive-grid", children: driveFiles.map((f) => {
|
|
159
|
+
const g = fileGlyph(f);
|
|
160
|
+
return (_jsxs("div", { className: `file-card ${f.is_folder ? "is-folder" : ""}`, onClick: () => openItem(f), onContextMenu: (e) => { e.preventDefault(); openMenuFor(f.id, e.currentTarget.querySelector(".file-menu-btn") ?? e.currentTarget); }, children: [_jsxs("div", { className: "file-thumb", children: [f.thumbnail_link && !f.is_folder ? (_jsx("img", { src: f.thumbnail_link, alt: "", loading: "lazy", onError: (e) => { e.currentTarget.style.display = "none"; } })) : null, _jsx(Icon, { name: g.icon, size: f.is_folder ? 34 : 30, className: "file-glyph", style: g.color ? { color: g.color } : undefined })] }), _jsxs("div", { className: "file-foot", children: [_jsxs(Button, { variant: "ghost", type: "button", className: "file-open", style: FILE_OPEN_STYLE, children: [_jsx(Icon, { name: g.icon, size: 15, className: "file-foot-ic", style: g.color ? { color: g.color } : undefined }), _jsx("span", { className: "file-name", title: f.name, children: f.name })] }), _jsx(IconButton, { label: "More", className: "file-menu-btn", onClick: (e) => { e.stopPropagation(); if (menuFor === f.id)
|
|
161
|
+
setMenuFor(null);
|
|
162
|
+
else
|
|
163
|
+
openMenuFor(f.id, e.currentTarget); }, children: _jsx(Icon, { name: "more", size: 16 }) })] }), menuFor === f.id && menuAnchor && _jsx(DriveFileMenu, { f: f, anchor: menuAnchor, onClose: () => setMenuFor(null), onPreview: () => setDrivePreview(f), drive: drive, onMove: openMoveDialog, onShare: openShareDialog })] }, f.id));
|
|
164
|
+
}) })) : (_jsx("div", { className: "drive-list", children: driveFiles.map((f) => {
|
|
165
|
+
const g = fileGlyph(f);
|
|
166
|
+
return (_jsxs("div", { className: "file-row", onClick: () => openItem(f), onContextMenu: (e) => { e.preventDefault(); openMenuFor(f.id, e.currentTarget.querySelector(".file-menu-btn") ?? e.currentTarget); }, children: [_jsxs(Button, { variant: "ghost", type: "button", className: "file-open", style: { ...FILE_OPEN_STYLE, gap: 12 }, children: [_jsx(Icon, { name: g.icon, size: 18, className: "file-row-ic", style: g.color ? { color: g.color } : undefined }), _jsx("span", { className: "file-row-name", title: f.name, children: f.name }), _jsx("span", { className: "file-row-date", children: f.modified_time ? new Date(f.modified_time).toLocaleDateString() : "" })] }), _jsx(IconButton, { label: "More", className: "file-menu-btn", onClick: (e) => { e.stopPropagation(); if (menuFor === f.id)
|
|
167
|
+
setMenuFor(null);
|
|
168
|
+
else
|
|
169
|
+
openMenuFor(f.id, e.currentTarget); }, children: _jsx(Icon, { name: "more", size: 16 }) }), menuFor === f.id && menuAnchor && _jsx(DriveFileMenu, { f: f, anchor: menuAnchor, onClose: () => setMenuFor(null), onPreview: () => setDrivePreview(f), drive: drive, onMove: openMoveDialog, onShare: openShareDialog })] }, f.id));
|
|
170
|
+
}) })), driveToken && !driveBusy && (_jsx(Button, { variant: "ghost", className: "label-more drive-loadmore", onClick: () => {
|
|
171
|
+
if (!activeAccount)
|
|
172
|
+
return;
|
|
173
|
+
listDriveFiles(activeAccount, driveShelf === "my" && drivePath.length ? drivePath[drivePath.length - 1].id : undefined, driveQuery || undefined, driveToken, driveShelf === "my" ? undefined : driveShelf, driveType || undefined)
|
|
174
|
+
.then((l) => {
|
|
175
|
+
setDriveFiles((prev) => [...prev, ...l.files]);
|
|
176
|
+
setDriveToken(l.next_page_token);
|
|
177
|
+
})
|
|
178
|
+
.catch((e) => setErr(String(e)));
|
|
179
|
+
}, children: "Load more" }))] }), drivePreview && (_jsx(DrivePreviewModal, { drivePreview: drivePreview, setDrivePreview: setDrivePreview, drive: drive })), moveFile && (_jsx(Dialog, { title: `Move “${moveFile.name}” to…`, width: 480, onClose: () => setMoveFile(null), children: _jsxs("div", { className: "drive-dialog-list", children: [_jsxs(Button, { variant: "ghost", className: "ws-row drive-pick", onClick: () => doMove("root", "My Drive"), children: [_jsx(Icon, { name: "drive", size: 16, className: "ws-ic" }), _jsx("span", { className: "ws-title", children: "My Drive (root)" })] }), moveFolders.map((folder) => (_jsxs(Button, { variant: "ghost", className: "ws-row drive-pick", onClick: () => doMove(folder.id, folder.name), children: [_jsx(Icon, { name: "folder", size: 16, className: "ws-ic" }), _jsx("span", { className: "ws-title", children: folder.name })] }, folder.id))), moveFolders.length === 0 && _jsx("p", { className: "hint", children: "No folders found." })] }) })), shareFile && (_jsxs(Dialog, { title: `Share “${shareFile.name}”`, width: 480, onClose: () => setShareFile(null), children: [_jsxs("div", { className: "share-add", children: [_jsx(Input, { className: "drive-search", placeholder: "Add person by email\u2026", value: shareEmail, onChange: (e) => setShareEmail(e.target.value), onKeyDown: (e) => { if (e.key === "Enter")
|
|
180
|
+
addShare(); } }), _jsx(Select, { className: "workspace-select", label: "Access level", value: shareRole, options: [
|
|
181
|
+
{ value: "reader", label: "Viewer" },
|
|
182
|
+
{ value: "commenter", label: "Commenter" },
|
|
183
|
+
{ value: "writer", label: "Editor" },
|
|
184
|
+
], onChange: setShareRole }), _jsx(Button, { variant: "primary", size: "sm", onClick: addShare, disabled: !shareEmail.trim(), children: "Share" })] }), _jsx("div", { className: "drive-dialog-list", children: perms.length === 0 ? (_jsx("p", { className: "hint", children: "No one else has access yet." })) : (perms.map((p) => (_jsxs("div", { className: "ws-row", children: [_jsx(Icon, { name: "people", size: 15, className: "ws-ic" }), _jsx("span", { className: "ws-title", children: p.email || p.display_name || p.kind || "—" }), _jsx("span", { className: "ws-sub", children: p.role }), p.role !== "owner" && (_jsx(Button, { variant: "danger", size: "sm", onClick: () => revokeShare(p.id), children: "Remove" }))] }, p.id)))) })] }))] }));
|
|
185
|
+
}
|
|
186
|
+
export const DriveView = memo(DriveViewInner);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function MeetView(): import("react").JSX.Element;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Meet — a landing surface (parity with meet.google.com's home): start an
|
|
3
|
+
// instant meeting, join with a code, email a link, or schedule in Calendar.
|
|
4
|
+
// The live video call is Google's WebRTC client, so starting/joining launches it
|
|
5
|
+
// externally (openExternal) — every desktop client does this.
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import Icon from "@rightkit/mail-ui/components/Icons";
|
|
8
|
+
import { useAppStore } from "@rightkit/mail-ui/store/index";
|
|
9
|
+
import { createMeet, openExternal } from "@rightkit/mail-ui/lib/ipc";
|
|
10
|
+
import { Button, Input } from "@rightkit/react-shell/react";
|
|
11
|
+
/** Accept a raw code ("abc-defg-hij"), a full URL, or a code with spaces. */
|
|
12
|
+
function meetUrlFromCode(input) {
|
|
13
|
+
const s = input.trim();
|
|
14
|
+
if (!s)
|
|
15
|
+
return null;
|
|
16
|
+
if (/^https?:\/\//i.test(s))
|
|
17
|
+
return s;
|
|
18
|
+
const code = s.replace(/^meet\.google\.com\//i, "").replace(/\s+/g, "");
|
|
19
|
+
if (!/^[a-z0-9-]{3,}$/i.test(code))
|
|
20
|
+
return null;
|
|
21
|
+
return `https://meet.google.com/${code}`;
|
|
22
|
+
}
|
|
23
|
+
export function MeetView() {
|
|
24
|
+
const activeAccount = useAppStore((s) => s.activeAccount);
|
|
25
|
+
const setErr = useAppStore((s) => s.setErr);
|
|
26
|
+
const showUndo = useAppStore((s) => s.showUndo);
|
|
27
|
+
const openComposeTo = useAppStore((s) => s.openComposeTo);
|
|
28
|
+
const setEventSeed = useAppStore((s) => s.setEventSeed);
|
|
29
|
+
const setView = useAppStore((s) => s.setView);
|
|
30
|
+
const [code, setCode] = useState("");
|
|
31
|
+
const [starting, setStarting] = useState(false);
|
|
32
|
+
async function startInstant() {
|
|
33
|
+
if (!activeAccount || starting)
|
|
34
|
+
return;
|
|
35
|
+
setStarting(true);
|
|
36
|
+
try {
|
|
37
|
+
const sp = await createMeet(activeAccount);
|
|
38
|
+
if (sp.meeting_uri) {
|
|
39
|
+
await openExternal(sp.meeting_uri);
|
|
40
|
+
showUndo(`Meet ready · ${sp.meeting_code}`, [], "");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
setErr(String(e));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
setStarting(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function emailLink() {
|
|
51
|
+
if (!activeAccount)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
const sp = await createMeet(activeAccount);
|
|
55
|
+
const uri = sp.meeting_uri || "";
|
|
56
|
+
const body = `<p>You're invited to a Google Meet video call.</p>` +
|
|
57
|
+
(uri ? `<p>Join: <a href="${uri}">${uri}</a></p>` : "") +
|
|
58
|
+
(sp.meeting_code ? `<p>Meeting code: ${sp.meeting_code}</p>` : "");
|
|
59
|
+
openComposeTo("", "Meeting invitation", body);
|
|
60
|
+
showUndo("Meeting link added to a new email", [], "");
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
setErr(String(e));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function join() {
|
|
67
|
+
const url = meetUrlFromCode(code);
|
|
68
|
+
if (!url) {
|
|
69
|
+
setErr("Enter a valid meeting code or link (e.g. abc-defg-hij).");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
openExternal(url).catch((e) => setErr(String(e)));
|
|
73
|
+
}
|
|
74
|
+
function schedule() {
|
|
75
|
+
setEventSeed({ summary: "Meeting", withMeet: true });
|
|
76
|
+
setView("calendar");
|
|
77
|
+
}
|
|
78
|
+
const disabled = !activeAccount;
|
|
79
|
+
return (_jsx("div", { className: "meetview", children: _jsxs("div", { className: "meet-hero", children: [_jsx("div", { className: "meet-mark", children: _jsx(Icon, { name: "video", size: 40 }) }), _jsx("h1", { className: "meet-title", children: "Video calls and meetings for everyone" }), _jsx("p", { className: "meet-sub", children: "Connect, collaborate, and celebrate from anywhere with Google Meet. The call opens in your browser, where you're already signed in." }), _jsxs("div", { className: "meet-actions", children: [_jsxs(Button, { variant: "primary", className: "meet-start", disabled: disabled || starting, onClick: startInstant, children: [_jsx(Icon, { name: "video", size: 18 }), " ", starting ? "Creating…" : "New meeting"] }), _jsxs("div", { className: "meet-join", children: [_jsx(Icon, { name: "people", size: 18, className: "meet-join-ic" }), _jsx(Input, { className: "meet-code", placeholder: "Enter a code or link", value: code, onChange: (e) => setCode(e.target.value), onKeyDown: (e) => { if (e.key === "Enter")
|
|
80
|
+
join(); } }), _jsx(Button, { variant: "ghost", size: "sm", className: "meet-join-btn", disabled: !code.trim(), onClick: join, children: "Join" })] })] }), _jsxs("div", { className: "meet-secondary", children: [_jsxs(Button, { variant: "ghost", disabled: disabled, onClick: schedule, children: [_jsx(Icon, { name: "calendar", size: 16 }), " Schedule in Calendar"] }), _jsxs(Button, { variant: "ghost", disabled: disabled, onClick: emailLink, children: [_jsx(Icon, { name: "draft", size: 16 }), " Email a meeting link"] })] })] }) }));
|
|
81
|
+
}
|