Merge pull request #99 from LINUXexpert-org/shared-calendars

Shared calendars in the calendar, and no more account switcher
This commit is contained in:
LINUXexpert.org
2026-08-27 11:02:48 -07:00
committed by GitHub
5 changed files with 171 additions and 28 deletions
+12 -3
View File
@@ -179,6 +179,12 @@ const identities: Obj[] = [
];
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
const sieveScripts: Obj[] = [];
/* 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. */
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 sharedEvents: Obj[] = [];
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
const events: Obj[] = [];
@@ -191,6 +197,9 @@ const events: Obj[] = [];
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
// Two in the shared account, so a colleague's calendar has something in it.
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
}
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
@@ -722,10 +731,10 @@ const handlers: Record<string, Handler> = {
"SieveScript/get": genericGet(sieveScripts),
"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 }),
"Calendar/get": genericGet(calendars),
"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 })),
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
"CalendarEvent/get": genericGet(events),
"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),
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
+106 -2
View File
@@ -15,10 +15,26 @@ export interface EventInstance {
calendar: Calendar | undefined;
}
/** A calendar somebody else shared, and the account it lives in. */
export interface SharedCalendar {
accountId: Id;
accountName: string;
calendar: Calendar;
}
/** Shared events are keyed by account too: ids only differ within an account. */
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
interface CalendarState {
accountId: Id | null;
available: boolean;
calendars: Record<Id, Calendar>;
/** Calendars shared with the reader, from every non-personal account. */
sharedCalendars: SharedCalendar[];
/** Their events, keyed by account and id. See `sharedKey`. */
sharedEvents: Record<string, CalendarEvent>;
/** Which shared keys each loaded window holds, alongside `ranges`. */
sharedRanges: Record<string, string[]>;
events: Record<Id, CalendarEvent>;
/** Loaded ranges keyed "start|end" → event ids */
ranges: Record<string, Id[]>;
@@ -29,6 +45,9 @@ interface CalendarState {
init(): Promise<void>;
loadCalendars(): Promise<void>;
/** Calendars from accounts that shared with the reader, and their events. */
loadSharedCalendars(): Promise<void>;
loadSharedRange(start: Date, end: Date): Promise<void>;
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
instancesIn(start: Date, end: Date): EventInstance[];
getEvent(id: Id): Promise<CalendarEvent | null>;
@@ -65,6 +84,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
accountId: null,
available: false,
calendars: {},
sharedCalendars: [],
sharedEvents: {},
sharedRanges: {},
events: {},
ranges: {},
loading: false,
@@ -73,12 +95,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
hidden: {},
async init() {
const accountId = useSession.getState().accountFor(CAP.calendars);
// The reader's own: a shared calendar is shown beside theirs, not instead.
const accountId = useSession.getState().ownAccountFor(CAP.calendars);
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
set({ available });
if (!available) return;
await get().loadCalendars();
void get().loadSharedCalendars();
try {
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
set({ identities: res.list });
@@ -87,6 +111,67 @@ export const useCalendar = create<CalendarState>((set, get) => ({
}
},
/*
* Calendars other people shared, and the events in them.
*
* Kept apart from the reader's own and keyed by account, for the reason ids
* force: they are unique only within an account. Loaded from the same window
* the reader is looking at, so a colleague's calendar fills in beside their
* own rather than after a separate wait.
*
* An account that answers with no calendars is simply not listed. Sharing a
* file does not make somebody's calendar worth a heading.
*/
async loadSharedCalendars() {
const session = useSession.getState();
const own = session.ownAccountFor(CAP.calendars);
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
const found: SharedCalendar[] = [];
for (const [accountId, account] of accounts) {
try {
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
} catch {
continue;
}
}
set({ sharedCalendars: found });
// Fill in whatever windows are already on screen.
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. */
async loadSharedRange(start, end) {
const shared = get().sharedCalendars;
if (!shared.length) return;
const key = `${start.getTime()}|${end.getTime()}`;
const tz = settings().timeZone ?? browserTimeZone;
const accounts = [...new Set(shared.map((c) => c.accountId))];
const ids: string[] = [];
const events: Record<string, CalendarEvent> = {};
for (const accountId of accounts) {
try {
const res = await client.chain([
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: tz }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
for (const e of g.list) {
const k = sharedKey(accountId, e.id);
events[k] = e;
ids.push(k);
}
} catch {
// One account refusing must not empty the calendar of the others.
continue;
}
}
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: { ...s.sharedRanges, [key]: ids } }));
},
async loadCalendars() {
const accountId = get().accountId;
if (!accountId) return;
@@ -131,13 +216,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
for (const e of g.list) events[e.id] = e;
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
});
void get().loadSharedRange(start, end);
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
instancesIn(start, end) {
const { events, ranges, calendars, hidden } = get();
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
const ids = new Set<Id>();
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
const out: EventInstance[] = [];
@@ -150,6 +236,24 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (!inst) continue;
if (inst.end > start && inst.start < end) out.push(inst);
}
/* Shared events go through the same funnel, so every view gets them
without knowing they exist. Their calendars are looked up per account:
a shared calendar id means nothing outside the account holding it, and
hiding one is remembered under the same account-qualified key. */
const sharedKeys = new Set<string>();
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
for (const k of sharedKeys) {
const e = sharedEvents[k];
if (!e) continue;
const accountId = k.slice(0, k.length - e.id.length - 1);
const calId = Object.keys(e.calendarIds ?? {})[0];
if (calId && hidden[sharedKey(accountId, calId)]) continue;
const theirs: Record<Id, Calendar> = {};
for (const c of sharedCalendars) if (c.accountId === accountId) theirs[c.calendar.id] = c.calendar;
const inst = toInstance(e, theirs);
if (!inst) continue;
if (inst.end > start && inst.start < end) out.push(inst);
}
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
return out;
},
+19 -6
View File
@@ -133,14 +133,27 @@ export const useFiles = create<FilesState>((set, get) => ({
* Which accounts hold shared files cannot be worked out from capabilities:
* Stalwart advertises the whole set on a shared account -- mail, calendars,
* contacts and the rest -- identical to a personal one, whatever was
* actually shared (checked on 0.16.19, 2026-08-27). So every account that
* is not the reader's own is offered, and what it really holds is settled
* by asking it for its folders and showing what comes back.
* actually shared (checked on 0.16.19, 2026-08-27). So each one is asked
* for its files, and only the ones that answer with any are listed.
*
* Listing them all and letting the folders speak for themselves was the
* first attempt, and it put an account holding nothing at all under
* "Shared with me" -- an invitation to open an empty pane, offered by an
* account whose calendar or contacts were the thing actually shared. An
* account that shares no files does not belong in a list of shared files.
*/
const s = session.session;
const sharedAccounts = Object.entries(s?.accounts ?? {})
.filter(([, a]) => a.isPersonal === false)
.map(([id, a]) => ({ id, name: a.name }));
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
const sharedAccounts: SharedAccount[] = [];
for (const [id, a] of candidates) {
try {
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
} catch {
// Refused means nothing here is ours to see, which is the same answer.
continue;
}
}
// Stay where the reader is if they are reading a share that still exists.
const browsing = get().accountId;
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
+12 -16
View File
@@ -6,7 +6,7 @@ import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree";
@@ -14,7 +14,6 @@ import { ContactsSidebar } from "./contacts/ContactsSidebar";
import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { formatSize } from "@/lib/format";
import { CAP } from "@/jmap/client";
const PUSH_LABEL = {
connected: "Live updates connected",
@@ -32,8 +31,6 @@ export function AppShell({ children }: { children: ReactNode }) {
const openCompose = useCompose((s) => s.open);
const pushState = useSession((s) => s.pushState);
const session = useSession((s) => s.session);
const accountId = useSession((s) => s.accountId);
const setAccount = useSession((s) => s.setAccount);
const logout = useSession((s) => s.logout);
const acctMenu = useMenu();
const section = location.split("/")[1] || "mail";
@@ -55,8 +52,16 @@ export function AppShell({ children }: { children: ReactNode }) {
}
}, [openCompose, navigate]);
const accounts = session ? Object.entries(session.accounts) : [];
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
/*
* There is no account switcher any more.
*
* It existed to reach what other people shared, and was the wrong door: it
* moved the whole app to somebody else's account, and Stalwart advertises
* every capability on a shared account, so mail, calendar and contacts went
* with it and were refused. Shares are listed where they belong now -- in
* Files and in Contacts, beside the reader's own -- and found without anyone
* having to know an account switch was involved.
*/
return (
<div className="app">
@@ -67,7 +72,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<Link href="/mail" className="brand">
<img src="/img/logo.png" alt="" />
<span className="brand-name">
ihasmail{mailAccounts.length > 1 ? "" : ""}
ihasmail
</span>
</Link>
<SearchBar />
@@ -95,15 +100,6 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
</div>
</div>
{mailAccounts.length > 1 && (
<>
<MenuSep />
<MenuTitle>Accounts</MenuTitle>
{mailAccounts.map(([id, a]) => (
<MenuItem key={id} checked={id === accountId} label={a.name} onClick={() => setAccount(id)} />
))}
</>
)}
<MenuSep />
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
+22 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Users } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
@@ -63,6 +63,27 @@ 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>
</div>
))}
{/* Calendars other people shared. Separate from the reader's own, the way
Files and Contacts separate theirs: you cannot edit these, and which
of them you can see at all is somebody else's decision. Hiding one is
remembered under an account-qualified key, since a calendar id means
nothing outside the account holding it. */}
{cal.sharedCalendars.length > 0 && (
<>
<div className="nav-section"><span>Shared with me</span></div>
{cal.sharedCalendars.map(({ accountId, accountName, calendar: c }) => {
const key = `${accountId}:${c.id}`;
return (
<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-name">{c.name}</span>
<Users size={12} className="faint" />
</div>
);
})}
</>
)}
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
{menuCal && (
<>