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 };
|
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
|
||||||
const sieveScripts: Obj[] = [];
|
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() }];
|
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 }; }
|
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||||
const events: Obj[] = [];
|
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: "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: "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" });
|
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 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 });
|
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/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/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": 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 })),
|
"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/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": genericGet(events),
|
"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
|
||||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
// 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.
|
// #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;
|
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 {
|
interface CalendarState {
|
||||||
accountId: Id | null;
|
accountId: Id | null;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
calendars: Record<Id, Calendar>;
|
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>;
|
events: Record<Id, CalendarEvent>;
|
||||||
/** Loaded ranges keyed "start|end" → event ids */
|
/** Loaded ranges keyed "start|end" → event ids */
|
||||||
ranges: Record<string, Id[]>;
|
ranges: Record<string, Id[]>;
|
||||||
@@ -29,6 +45,9 @@ interface CalendarState {
|
|||||||
|
|
||||||
init(): Promise<void>;
|
init(): Promise<void>;
|
||||||
loadCalendars(): 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>;
|
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>;
|
||||||
@@ -65,6 +84,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
accountId: null,
|
accountId: null,
|
||||||
available: false,
|
available: false,
|
||||||
calendars: {},
|
calendars: {},
|
||||||
|
sharedCalendars: [],
|
||||||
|
sharedEvents: {},
|
||||||
|
sharedRanges: {},
|
||||||
events: {},
|
events: {},
|
||||||
ranges: {},
|
ranges: {},
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -73,12 +95,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
hidden: {},
|
hidden: {},
|
||||||
|
|
||||||
async init() {
|
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));
|
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
|
||||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||||
set({ available });
|
set({ available });
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
await get().loadCalendars();
|
await get().loadCalendars();
|
||||||
|
void get().loadSharedCalendars();
|
||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
||||||
set({ identities: res.list });
|
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() {
|
async loadCalendars() {
|
||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
@@ -131,13 +216,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
for (const e of g.list) events[e.id] = e;
|
for (const e of g.list) events[e.id] = e;
|
||||||
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
|
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
|
||||||
});
|
});
|
||||||
|
void get().loadSharedRange(start, end);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: (err as Error).message });
|
set({ loading: false, error: (err as Error).message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
instancesIn(start, end) {
|
instancesIn(start, end) {
|
||||||
const { events, ranges, calendars, hidden } = get();
|
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
|
||||||
const ids = new Set<Id>();
|
const ids = new Set<Id>();
|
||||||
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
|
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
|
||||||
const out: EventInstance[] = [];
|
const out: EventInstance[] = [];
|
||||||
@@ -150,6 +236,24 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
/* 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());
|
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
|
||||||
return out;
|
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:
|
* Which accounts hold shared files cannot be worked out from capabilities:
|
||||||
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
||||||
* contacts and the rest -- identical to a personal one, whatever was
|
* 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
|
* actually shared (checked on 0.16.19, 2026-08-27). So each one is asked
|
||||||
* is not the reader's own is offered, and what it really holds is settled
|
* for its files, and only the ones that answer with any are listed.
|
||||||
* by asking it for its folders and showing what comes back.
|
*
|
||||||
|
* 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 s = session.session;
|
||||||
const sharedAccounts = Object.entries(s?.accounts ?? {})
|
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
||||||
.filter(([, a]) => a.isPersonal === false)
|
const sharedAccounts: SharedAccount[] = [];
|
||||||
.map(([id, a]) => ({ id, name: a.name }));
|
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.
|
// Stay where the reader is if they are reading a share that still exists.
|
||||||
const browsing = get().accountId;
|
const browsing = get().accountId;
|
||||||
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
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 { useMail } from "@/store/mail";
|
||||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
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 { SearchBar } from "./SearchBar";
|
||||||
import { MailboxTree } from "./mail/MailboxTree";
|
import { MailboxTree } from "./mail/MailboxTree";
|
||||||
import { FilesTree } from "./files/FilesTree";
|
import { FilesTree } from "./files/FilesTree";
|
||||||
@@ -14,7 +14,6 @@ import { ContactsSidebar } from "./contacts/ContactsSidebar";
|
|||||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
import { CAP } from "@/jmap/client";
|
|
||||||
|
|
||||||
const PUSH_LABEL = {
|
const PUSH_LABEL = {
|
||||||
connected: "Live updates connected",
|
connected: "Live updates connected",
|
||||||
@@ -32,8 +31,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
const openCompose = useCompose((s) => s.open);
|
const openCompose = useCompose((s) => s.open);
|
||||||
const pushState = useSession((s) => s.pushState);
|
const pushState = useSession((s) => s.pushState);
|
||||||
const session = useSession((s) => s.session);
|
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 logout = useSession((s) => s.logout);
|
||||||
const acctMenu = useMenu();
|
const acctMenu = useMenu();
|
||||||
const section = location.split("/")[1] || "mail";
|
const section = location.split("/")[1] || "mail";
|
||||||
@@ -55,8 +52,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [openCompose, navigate]);
|
}, [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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -67,7 +72,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<Link href="/mail" className="brand">
|
<Link href="/mail" className="brand">
|
||||||
<img src="/img/logo.png" alt="" />
|
<img src="/img/logo.png" alt="" />
|
||||||
<span className="brand-name">
|
<span className="brand-name">
|
||||||
ihasmail{mailAccounts.length > 1 ? "" : ""}
|
ihasmail
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
@@ -95,15 +100,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
||||||
</div>
|
</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 />
|
<MenuSep />
|
||||||
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
||||||
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
||||||
|
|||||||
@@ -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 } from "lucide-react";
|
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Users } 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";
|
||||||
@@ -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>
|
<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
|
||||||
|
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}>
|
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
||||||
{menuCal && (
|
{menuCal && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user