Merge pull request #101 from LINUXexpert-org/subscribe-shares

Add shared collections deliberately, and pick recipients from the address books
This commit is contained in:
LINUXexpert.org
2026-08-27 11:21:22 -07:00
committed by GitHub
8 changed files with 330 additions and 21 deletions
+4 -4
View File
@@ -181,7 +181,7 @@ let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate:
const sieveScripts: Obj[] = []; const sieveScripts: Obj[] = [];
/* A calendar in the shared account, so "Shared with me" and a colleague's /* A calendar in the shared account, so "Shared with me" and a colleague's
events appearing in the grid can be exercised. Read-only, as a share is. */ events appearing in the grid can be exercised. Read-only, as a share is. */
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }]; const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
const sharedEvents: Obj[] = []; const sharedEvents: Obj[] = [];
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events); const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars); const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
@@ -207,7 +207,7 @@ const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, s
/* A book in the shared account, so "Shared with me" and addressing a message /* A book in the shared account, so "Shared with me" and addressing a message
from somebody else's contacts can be exercised at all. Read-only, which is from somebody else's contacts can be exercised at all. Read-only, which is
what a share usually is. */ what a share usually is. */
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights(false) }]; const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
const sharedCards: Obj[] = [ const sharedCards: Obj[] = [
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, { id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, { id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
@@ -732,7 +732,7 @@ const handlers: Record<string, Handler> = {
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; }, "SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }), "SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a), "Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })), "Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; }, "CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a), "CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
@@ -751,7 +751,7 @@ const handlers: Record<string, Handler> = {
"Principal/get": genericGet(principals), "Principal/get": genericGet(principals),
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }), "Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a), "AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })), "AddressBook/set": (a) => genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a),
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; }, "ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a), "ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
"ContactCard/set": genericSet(cards, "cc"), "ContactCard/set": genericSet(cards, "cc"),
+29 -1
View File
@@ -48,6 +48,8 @@ interface CalendarState {
/** Calendars from accounts that shared with the reader, and their events. */ /** Calendars from accounts that shared with the reader, and their events. */
loadSharedCalendars(): Promise<void>; loadSharedCalendars(): Promise<void>;
loadSharedRange(start: Date, end: Date): Promise<void>; loadSharedRange(start: Date, end: Date): Promise<void>;
/** Add a shared calendar to, or remove it from, the reader's own view. */
setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>;
loadRange(start: Date, end: Date, force?: boolean): Promise<void>; loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
instancesIn(start: Date, end: Date): EventInstance[]; instancesIn(start: Date, end: Date): EventInstance[];
getEvent(id: Id): Promise<CalendarEvent | null>; getEvent(id: Id): Promise<CalendarEvent | null>;
@@ -143,6 +145,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
} }
}, },
async setSharedSubscribed(accountId, calendarId, subscribed) {
try {
await client.call("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } });
} catch (err) {
set({ error: (err as Error).message });
return;
}
set((s) => ({
sharedCalendars: s.sharedCalendars.map((c) =>
c.accountId === accountId && c.calendar.id === calendarId ? { ...c, calendar: { ...c.calendar, isSubscribed: subscribed } } : c,
),
}));
// Its events are only fetched for calendars in view, so the windows on
// screen have to be asked again either way.
for (const key of Object.keys(get().ranges)) {
const [from, to] = key.split("|").map((n) => new Date(Number(n)));
if (from && to) void get().loadSharedRange(from, to);
}
},
/** The same window, from every account that shared a calendar. */ /** The same window, from every account that shared a calendar. */
async loadSharedRange(start, end) { async loadSharedRange(start, end) {
const shared = get().sharedCalendars; const shared = get().sharedCalendars;
@@ -248,8 +270,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = k.slice(0, k.length - e.id.length - 1); const accountId = k.slice(0, k.length - e.id.length - 1);
const calId = Object.keys(e.calendarIds ?? {})[0]; const calId = Object.keys(e.calendarIds ?? {})[0];
if (calId && hidden[sharedKey(accountId, calId)]) continue; if (calId && hidden[sharedKey(accountId, calId)]) continue;
/* Stalwart hands back every calendar in an account the reader can reach,
with full rights on each, whether or not anybody meant to share it --
an account linked for its files offered its calendar too. `isSubscribed`
is the only thing separating "shared with me" from "reachable", so
nothing unsubscribed is drawn. */
const theirs: Record<Id, Calendar> = {}; const theirs: Record<Id, Calendar> = {};
for (const c of sharedCalendars) if (c.accountId === accountId) theirs[c.calendar.id] = c.calendar; for (const c of sharedCalendars) if (c.accountId === accountId && c.calendar.isSubscribed) theirs[c.calendar.id] = c.calendar;
if (calId && !theirs[calId]) continue;
const inst = toInstance(e, theirs); const inst = toInstance(e, theirs);
if (!inst) continue; if (!inst) continue;
if (inst.end > start && inst.start < end) out.push(inst); if (inst.end > start && inst.start < end) out.push(inst);
+31 -1
View File
@@ -53,6 +53,8 @@ interface ContactsState {
/** Books and cards from accounts that shared with the reader. */ /** Books and cards from accounts that shared with the reader. */
loadShared(): Promise<void>; loadShared(): Promise<void>;
select(selection: BookSelection): void; select(selection: BookSelection): void;
/** Add a shared address book to, or remove it from, the reader's own view. */
setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise<void>;
/** The account a card belongs to, null for the reader's own. */ /** The account a card belongs to, null for the reader's own. */
accountOfCard(id: Id): Id | null; accountOfCard(id: Id): Id | null;
getCard(id: Id): Promise<ContactCard | null>; getCard(id: Id): Promise<ContactCard | null>;
@@ -131,6 +133,18 @@ export const useContacts = create<ContactsState>((set, get) => ({
try { try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null }); const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
for (const book of res.list) books.push({ accountId, accountName: account.name, book }); for (const book of res.list) books.push({ accountId, accountName: account.name, book });
/*
* Cards come only from books the reader has added.
*
* Stalwart hands back every book in a reachable account with full
* rights on each, shared or not -- an account linked for its files
* offered its address book too -- so `isSubscribed` is the only thing
* separating "shared with me" from "reachable". Loading the rest would
* put a stranger's contacts in the To field, which is the one place
* this must not guess.
*/
const wanted = new Set(res.list.filter((b) => b.isSubscribed).map((b) => b.id));
if (!wanted.size) continue;
// One page. A shared book is a colleague's contacts, not an archive, // One page. A shared book is a colleague's contacts, not an archive,
// and the alternative is holding the reader's own list hostage to it. // and the alternative is holding the reader's own list hostage to it.
const cardsRes = await client.chain([ const cardsRes = await client.chain([
@@ -138,7 +152,10 @@ export const useContacts = create<ContactsState>((set, get) => ({
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"], ["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
]); ]);
const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>; const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) cards[sharedKey(accountId, c.id)] = c; for (const c of g.list) {
if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue;
cards[sharedKey(accountId, c.id)] = c;
}
} catch { } catch {
// An account that refuses is one that shared nothing here. Not an // An account that refuses is one that shared nothing here. Not an
// error to show: the reader did not ask for it and cannot act on it. // error to show: the reader did not ask for it and cannot act on it.
@@ -148,6 +165,19 @@ export const useContacts = create<ContactsState>((set, get) => ({
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true }); set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
}, },
async setBookSubscribed(accountId, bookId, subscribed) {
try {
await client.call("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } });
} catch (err) {
set({ error: (err as Error).message });
return;
}
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
set({ selection: { accountId: null, bookId: "all" } });
}
await get().loadShared();
},
select(selection) { select(selection) {
set({ selection }); set({ selection });
}, },
+5
View File
@@ -1030,3 +1030,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.spin { animation: spin 1s linear infinite; } .spin { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } } @media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
/* The composer's To label doubles as the way into the address books. */
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.composer-field label .link-btn:hover { color: var(--accent); }
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
+37 -9
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Users } from "lucide-react"; import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, X } from "lucide-react";
import { useCalendar } from "@/store/calendar"; import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings"; import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
@@ -25,6 +25,8 @@ export function CalendarSidebar() {
const [anchor, setAnchor] = useState(() => startOfDay(selected)); const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu(); const menu = useMenu();
const sharedSubscribed = cal.sharedCalendars.filter((c) => c.calendar.isSubscribed);
const sharedAvailable = cal.sharedCalendars.filter((c) => !c.calendar.isSubscribed);
const [menuCal, setMenuCal] = useState<Calendar | null>(null); const [menuCal, setMenuCal] = useState<Calendar | null>(null);
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null); const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(null); const [share, setShare] = useState<Calendar | null>(null);
@@ -63,26 +65,52 @@ export function CalendarSidebar() {
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button> <button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
</div> </div>
))} ))}
{/* Calendars other people shared. Separate from the reader's own, the way {/* Calendars other people shared, split by whether the reader has added
Files and Contacts separate theirs: you cannot edit these, and which them. Stalwart returns every calendar in a reachable account with full
of them you can see at all is somebody else's decision. Hiding one is rights, so "shared with me" and "there is an account here at all" look
remembered under an account-qualified key, since a calendar id means identical -- `isSubscribed` is the only thing that tells them apart,
nothing outside the account holding it. */} and adding one is a deliberate act rather than a guess on our part. */}
{cal.sharedCalendars.length > 0 && ( {sharedSubscribed.length > 0 && (
<> <>
<div className="nav-section"><span>Shared with me</span></div> <div className="nav-section"><span>Shared with me</span></div>
{cal.sharedCalendars.map(({ accountId, accountName, calendar: c }) => { {sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
const key = `${accountId}:${c.id}`; const key = `${accountId}:${c.id}`;
return ( return (
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}> <div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} /> <span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span> <span className="cal-name">{c.name}</span>
<Users size={12} className="faint" /> <button
className="icon-btn xs nav-more"
title="Remove from my calendar"
aria-label="Remove from my calendar"
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
>
<X size={14} />
</button>
</div> </div>
); );
})} })}
</> </>
)} )}
{sharedAvailable.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
<span className="cal-name faint">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Add to my calendar"
aria-label="Add to my calendar"
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
>
<Plus size={14} />
</button>
</div>
))}
</>
)}
<Popover anchor={menu.anchor} onClose={menu.close} width={220}> <Popover anchor={menu.anchor} onClose={menu.close} width={220}>
{menuCal && ( {menuCal && (
+26 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
import { useCompose, type Draft } from "@/store/compose"; import { useCompose, type Draft } from "@/store/compose";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
@@ -13,6 +13,7 @@ import { htmlToText, textToHtml } from "@/lib/text";
import { isValidEmail } from "@/lib/address"; import { isValidEmail } from "@/lib/address";
import { attachmentIcon } from "../mail/MessageView"; import { attachmentIcon } from "../mail/MessageView";
import { FilePicker } from "./FilePicker"; import { FilePicker } from "./FilePicker";
import { RecipientPicker, type Field } from "./RecipientPicker";
import { useFiles } from "@/store/files"; import { useFiles } from "@/store/files";
import { keyboard } from "@/lib/keyboard"; import { keyboard } from "@/lib/keyboard";
import { useIsMobile } from "@/ui/misc"; import { useIsMobile } from "@/ui/misc";
@@ -30,6 +31,7 @@ export function Composer({ draft }: { draft: Draft }) {
const addFromFiles = useCompose((s) => s.addFromFiles); const addFromFiles = useCompose((s) => s.addFromFiles);
const filesAvailable = useFiles((s) => s.available); const filesAvailable = useFiles((s) => s.available);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [addressBookOpen, setAddressBookOpen] = useState(false);
const removeAttachment = useCompose((s) => s.removeAttachment); const removeAttachment = useCompose((s) => s.removeAttachment);
const setIdentity = useCompose((s) => s.setIdentity); const setIdentity = useCompose((s) => s.setIdentity);
const insertTemplate = useCompose((s) => s.insertTemplate); const insertTemplate = useCompose((s) => s.insertTemplate);
@@ -174,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) {
</div> </div>
)} )}
<div className="composer-field"> <div className="composer-field">
<label htmlFor={`${key}-to`}>To</label> <label htmlFor={`${key}-to`}>
{/* Opens the address books. Autocomplete only helps someone who
already knows the name they are half-way through typing. */}
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
</label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} /> <RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
<span className="field-extra"> <span className="field-extra">
{/* Beside Cc and Bcc, because that is where someone looks when
they are thinking about who the message goes to. The label
opens it too, for anyone who tries that first. */}
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>} {!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>} {!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>} {!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
@@ -244,6 +254,20 @@ export function Composer({ draft }: { draft: Draft }) {
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> <MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />} {canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
</Popover> </Popover>
{addressBookOpen && (
<RecipientPicker
onPick={(field: Field, addresses) => {
// Added to whatever is already there, and the field is opened if
// it was hidden -- picking a Bcc should not put one somewhere
// the writer cannot see it.
const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc;
const merged = [...existing];
for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a);
patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) });
}}
onClose={() => setAddressBookOpen(false)}
/>
)}
{pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />} {pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />}
{canSchedule && scheduleOpen && ( {canSchedule && scheduleOpen && (
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} /> <ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
+161
View File
@@ -0,0 +1,161 @@
import { useMemo, useState } from "react";
import { Book, BookOpen, Search, Users, X } from "lucide-react";
import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { contactDisplayName, contactEmails } from "@/lib/contacts";
import type { ContactCard, EmailAddress } from "@/jmap/types";
export type Field = "to" | "cc" | "bcc";
/** One selectable address: a card can carry several, so the address is the unit. */
interface Row {
key: string;
name: string | null;
email: string;
book: string;
}
/**
* Choose recipients by looking through the address books.
*
* Autocomplete answers "finish this name for me", which is only useful when the
* writer already knows who they want. This answers the other question -- who is
* there? -- so the books can be read rather than recalled, and several people
* picked in one pass rather than typed one at a time.
*
* Each address is its own row, not each person: someone with a work address and
* a personal one is a choice to make, and a picker that offered the card and
* quietly took the first address would make it for them.
*
* Shared books are in here on the same footing as the reader's own, which is
* the point of having added them -- with the account named, so it is never a
* mystery whose list a name came from.
*/
export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) {
const contacts = useContacts();
const [q, setQ] = useState("");
const [bookKey, setBookKey] = useState<string>("all");
const [picked, setPicked] = useState<Record<string, Row>>({});
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed);
const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
const rows = useMemo(() => {
const out: Row[] = [];
const push = (card: ContactCard, book: string, keyPrefix: string) => {
for (const a of contactEmails(card)) {
if (!a.email) continue;
out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book });
}
};
if (bookKey === "all" || !bookKey.includes(":")) {
for (const c of Object.values(contacts.cards)) {
if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue;
push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own");
}
}
if (bookKey === "all" || bookKey.includes(":")) {
for (const [key, card] of Object.entries(contacts.sharedCards)) {
const accountId = key.slice(0, key.length - card.id.length - 1);
const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]);
if (!inBook) continue;
if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue;
push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId);
}
}
const needle = q.trim().toLowerCase();
const filtered = needle
? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle))
: out;
return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email));
}, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]);
const chosen = Object.values(picked);
const toggle = (r: Row) =>
setPicked((p) => {
const next = { ...p };
if (next[r.key]) delete next[r.key];
else next[r.key] = r;
return next;
});
const send = (field: Field) => {
onPick(field, chosen.map((r) => ({ name: r.name, email: r.email })));
onClose();
};
return (
<Dialog
open
onClose={onClose}
title="Choose recipients"
size="lg"
footer={
<>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
</button>
</>
}
>
<div className="row gap-8" style={{ marginBottom: 10 }}>
{/* Same shape as the contact list's own search box. */}
<label className="search-input grow" style={{ height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={15} className="faint" />
<input
className="grow"
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
placeholder="Search names and addresses"
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
/>
</label>
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
<option value="all">All address books</option>
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
{subscribed.map((b) => (
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
{b.book.name} · {b.accountName}
</option>
))}
</select>
</div>
{chosen.length > 0 && (
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
{chosen.map((r) => (
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
{r.name ?? r.email} <X size={12} />
</button>
))}
</div>
)}
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
{!rows.length ? (
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
) : (
rows.map((r) => (
<label key={r.key} className="menu-item" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
<span className="grow truncate">
{r.name ?? r.email}
{r.name && <span className="hint"> · {r.email}</span>}
</span>
<span className="hint nowrap">{r.book}</span>
</label>
))
)}
</div>
{!ownBooks.length && !subscribed.length && (
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
)}
</Dialog>
);
}
+37 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users } from "lucide-react"; import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, Users, X } from "lucide-react";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import type { AddressBook } from "@/jmap/types"; import type { AddressBook } from "@/jmap/types";
@@ -58,6 +58,8 @@ export function ContactsSidebar() {
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
const sel = contacts.selection; const sel = contacts.selection;
const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId; const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId;
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed);
const available = contacts.sharedBooks.filter((b) => !b.book.isSubscribed);
return ( return (
<> <>
@@ -110,7 +112,7 @@ export function ContactsSidebar() {
<RefreshCw size={14} className={refreshing ? "spin" : ""} /> <RefreshCw size={14} className={refreshing ? "spin" : ""} />
</button> </button>
</div> </div>
{contacts.sharedBooks.map(({ accountId, accountName, book }) => ( {subscribed.map(({ accountId, accountName, book }) => (
<div <div
key={`${accountId}:${book.id}`} key={`${accountId}:${book.id}`}
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`} className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
@@ -119,14 +121,45 @@ export function ContactsSidebar() {
> >
<BookOpen size={17} /> <BookOpen size={17} />
<span className="grow truncate">{book.name}</span> <span className="grow truncate">{book.name}</span>
<button
className="icon-btn sm"
title="Remove from my contacts"
aria-label="Remove from my contacts"
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
>
<X size={13} />
</button>
</div> </div>
))} ))}
{!contacts.sharedBooks.length && ( {!subscribed.length && (
<p className="hint" style={{ padding: "4px 12px" }}> <p className="hint" style={{ padding: "4px 12px" }}>
{contacts.sharedLoaded ? "Nothing is shared with you." : "Looking…"} {contacts.sharedLoaded ? "Nothing added yet." : "Looking…"}
</p> </p>
)} )}
{/* Stalwart returns every book in a reachable account with full rights,
shared or not, so adding one is the reader's decision rather than a
guess made on their behalf. */}
{available.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
{available.map(({ accountId, accountName, book }) => (
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
<BookOpen size={17} className="faint" />
<span className="grow truncate faint">{book.name}</span>
<button
className="icon-btn sm"
title="Add to my contacts"
aria-label="Add to my contacts"
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
>
<Plus size={13} />
</button>
</div>
))}
</>
)}
{/* Import and export lived in the pane this replaced. */} {/* Import and export lived in the pane this replaced. */}
<div style={{ padding: "12px 8px" }} className="col gap-8"> <div style={{ padding: "12px 8px" }} className="col gap-8">
<label className="btn btn-sm btn-block"> <label className="btn btn-sm btn-block">