Files
pj d4c3a304b5 Sign in on a phone with no console work, in the browser's own session
Mobile OAuth reused the desktop client all along; what stopped it was the
browser. Sending the user out to Safari or Chrome backgrounds the app, iOS
suspends it, and the redirect carrying the code arrives at a socket nobody
is accepting on. The consent page now opens in front of the app instead, in
SFSafariViewController or a Chrome Custom Tab, so the loopback listener
stays live and the existing `installed` client is enough. Verified against
Google's real consent screen on a simulator and an emulator.

A per-platform client is still supported and is now an upgrade rather than a
prerequisite. On iOS it buys ASWebAuthenticationSession, which shares
Safari's session so nobody is asked to sign in to Google twice. Android
needs nothing: Custom Tabs share Chrome's cookies, measured rather than
assumed. iOS session sharing could not be confirmed on the simulator and
wants a real device.

Never an app-owned WebView: Google blocks it, and rightly, since a webview
the app controls can read the password typed into it.

Cancelling is no longer reported as a failure. AuthEvent carries a
`cancelled` flag, set by comparing against the constant every back-out path
returns, and Google's `access_denied` on desktop counts too.

Five frontend bugs found by driving the real UI, not by reading it: the
details card slid under the tab bar leaving its buttons unhittable; the
ghost click after a touch pressed a button in the card that tap had just
opened, opening the editor by itself; the swipe that pages the day was dead
over every read-only block; 84px of macOS traffic-light lane was reserved on
platforms with no traffic lights; and the desktop header ignored the top
safe area on an iPad. A first launch now says what to do next rather than
showing an empty grid, and accounts are named as Google accounts throughout.
2026-08-12 18:32:45 +05:30

111 lines
3.8 KiB
TypeScript

// Which calendars the grid draws, grouped by the account they came from. The checkbox writes
// straight through `useAccounts.setSelected`, which is already optimistic and reverts itself on a
// failure, so there is nothing to hold here.
//
// A calendar you do not own behaves differently once you try to edit it, so its role is on the
// row rather than hidden behind the event that will refuse to save.
import { useEffect, useMemo } from "react";
import type { Calendar } from "../ipc";
import { useAccounts } from "../store/useAccounts";
import { useCalendarView } from "../store/useCalendarView";
import { useOverlays } from "../store/useOverlays";
import { calSlot } from "./GridModel";
import { roleLabel } from "./overlayModel";
import { Sheet } from "./overlayShell";
interface Group {
accountId: string;
email: string;
calendars: Calendar[];
}
function group(calendars: readonly Calendar[], emails: ReadonlyMap<string, string>): Group[] {
const groups = new Map<string, Group>();
for (const calendar of calendars) {
let g = groups.get(calendar.accountId);
if (!g) {
g = {
accountId: calendar.accountId,
email: emails.get(calendar.accountId) ?? "Disconnected account",
calendars: [],
};
groups.set(calendar.accountId, g);
}
g.calendars.push(calendar);
}
for (const g of groups.values()) {
g.calendars.sort(
(a, b) => Number(b.primary) - Number(a.primary) || a.summary.localeCompare(b.summary),
);
}
return [...groups.values()].sort((a, b) => a.email.localeCompare(b.email));
}
export function CalendarList() {
const open = useOverlays((s) => s.open);
const close = useOverlays((s) => s.close);
const show = useOverlays((s) => s.show);
const accounts = useAccounts((s) => s.accounts);
const calendars = useAccounts((s) => s.calendars);
const setSelected = useAccounts((s) => s.setSelected);
const refresh = useAccounts((s) => s.refresh);
const showing = open === "calendars";
useEffect(() => {
if (showing) void refresh();
}, [showing, refresh]);
const groups = useMemo(
() => group(calendars, new Map(accounts.map((a) => [a.id, a.email]))),
[calendars, accounts],
);
const toggle = (calendar: Calendar) => {
void setSelected(calendar.id, !calendar.selected);
// The instance stream is filtered on the Rust side, so the span has to be asked for again.
void useCalendarView.getState().load();
};
return (
<Sheet open={showing} title="Calendars" onClose={close}>
{groups.length === 0 ? (
<div className="panel-empty">
<p>No calendars here yet.</p>
<button type="button" className="panel-button" onClick={() => show("accounts")}>
Connect a Google account
</button>
</div>
) : (
groups.map((g) => (
<div className="cal-group" key={g.accountId}>
<span className="field-label">{g.email}</span>
{g.calendars.map((calendar) => {
const role = roleLabel(calendar.accessRole);
return (
<label className="cal-row" key={calendar.id} title={calendar.description ?? undefined}>
<input
type="checkbox"
className="cal-check"
checked={calendar.selected}
onChange={() => toggle(calendar)}
/>
<span
className="cal-dot"
style={{ background: `var(--cal-${calSlot(calendar.colorHex, calendar.id)})` }}
/>
<span className="cal-name">{calendar.summary || "Untitled"}</span>
{role ? <span className="cal-role">{role}</span> : null}
</label>
);
})}
</div>
))
)}
</Sheet>
);
}
export default CalendarList;