Merge pull request #99 from LINUXexpert-org/shared-calendars
Shared calendars in the calendar, and no more account switcher
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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()} />
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user