diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 2211486..2ae5c55 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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:ada@example.org", 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 = { "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. diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 3204e38..542dc10 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -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; + /** Calendars shared with the reader, from every non-personal account. */ + sharedCalendars: SharedCalendar[]; + /** Their events, keyed by account and id. See `sharedKey`. */ + sharedEvents: Record; + /** Which shared keys each loaded window holds, alongside `ranges`. */ + sharedRanges: Record; events: Record; /** Loaded ranges keyed "start|end" → event ids */ ranges: Record; @@ -29,6 +45,9 @@ interface CalendarState { init(): Promise; loadCalendars(): Promise; + /** Calendars from accounts that shared with the reader, and their events. */ + loadSharedCalendars(): Promise; + loadSharedRange(start: Date, end: Date): Promise; loadRange(start: Date, end: Date, force?: boolean): Promise; instancesIn(start: Date, end: Date): EventInstance[]; getEvent(id: Id): Promise; @@ -65,6 +84,9 @@ export const useCalendar = create((set, get) => ({ accountId: null, available: false, calendars: {}, + sharedCalendars: [], + sharedEvents: {}, + sharedRanges: {}, events: {}, ranges: {}, loading: false, @@ -73,12 +95,14 @@ export const useCalendar = create((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>("ParticipantIdentity/get", { accountId, ids: null }); set({ identities: res.list }); @@ -87,6 +111,67 @@ export const useCalendar = create((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>("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 = {}; + 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; + 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((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(); 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((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(); + 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 = {}; + 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; }, diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 7df9a10..b20a563 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -133,14 +133,27 @@ export const useFiles = create((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("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)); diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index d01d792..a6421ad 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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 (
@@ -67,7 +72,7 @@ export function AppShell({ children }: { children: ReactNode }) { - ihasmail{mailAccounts.length > 1 ? "" : ""} + ihasmail @@ -95,15 +100,6 @@ export function AppShell({ children }: { children: ReactNode }) {
{session?.ihasmail?.loginName}
- {mailAccounts.length > 1 && ( - <> - - Accounts - {mailAccounts.map(([id, a]) => ( - setAccount(id)} /> - ))} - - )} } label="Settings" onClick={() => navigate("/settings")} /> } label="Refresh" onClick={() => window.location.reload()} /> diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 5110665..38756a7 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -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() { ))} + {/* 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 && ( + <> +
Shared with me
+ {cal.sharedCalendars.map(({ accountId, accountName, calendar: c }) => { + const key = `${accountId}:${c.id}`; + return ( +
cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}> + + {c.name} + +
+ ); + })} + + )} + {menuCal && ( <>