mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
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.
This commit is contained in:
1 parent
661100dfdc
commit
d4c3a304b5
31 files changed
+1319
-114
No files matched your search
@@ -55,7 +55,7 @@ export function Accounts() {
|
||||
return (
|
||||
<Sheet
|
||||
open={showing}
|
||||
title="Accounts"
|
||||
title="Google accounts"
|
||||
onClose={close}
|
||||
foot={
|
||||
confirming ? undefined : (
|
||||
@@ -67,7 +67,7 @@ export function Accounts() {
|
||||
disabled={connecting || phase === "working"}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
Connect an account
|
||||
Connect a Google account
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -78,8 +78,8 @@ export function Accounts() {
|
||||
body={
|
||||
<p>
|
||||
The token is revoked and every calendar, event and pending write stored on this
|
||||
computer for that account is deleted. Nothing changes in Google Calendar itself, and
|
||||
you can connect the account again afterwards.
|
||||
computer for that Google account is deleted. Nothing changes in Google Calendar
|
||||
itself, and you can connect it again afterwards.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Disconnect"
|
||||
@@ -127,7 +127,7 @@ export function Accounts() {
|
||||
|
||||
{accounts.length === 0 && !connecting ? (
|
||||
<p className="panel-note">
|
||||
No account is connected, so there is nothing to show on the grid yet.
|
||||
Connect a Google account to see its calendars here. Nothing syncs until you do.
|
||||
</p>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
|
||||
@@ -74,7 +74,7 @@ export function CalendarList() {
|
||||
<div className="panel-empty">
|
||||
<p>No calendars here yet.</p>
|
||||
<button type="button" className="panel-button" onClick={() => show("accounts")}>
|
||||
Connect an account
|
||||
Connect a Google account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -48,18 +48,26 @@ const TEXT = "M4 6h16M4 12h12M4 18h9";
|
||||
const USERS =
|
||||
"M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M12.5 7.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75";
|
||||
|
||||
/** Clear of the window edges, and clear of the one row of chrome that is always resident. */
|
||||
/** Clear of the window edges, and clear of the chrome that is always resident. */
|
||||
const EDGE = 8;
|
||||
|
||||
/**
|
||||
* What the card is allowed to cover, measured off the chrome rather than named in tokens.
|
||||
*
|
||||
* A desktop has one row of it at the top. A phone has two, the second along the bottom, and both
|
||||
* of them pad themselves out of the way of a notch and a home indicator, so their rectangles are
|
||||
* the only thing that knows how tall they really are. Reading `--titlebar-h` instead meant the
|
||||
* card was placed against the window: in landscape it came down over the tab bar, and since the
|
||||
* bars paint above it, Edit, Delete and Close ended up behind the tab bar and unhittable.
|
||||
*/
|
||||
function viewBounds(): Bounds {
|
||||
const bar = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--titlebar-h"),
|
||||
);
|
||||
const top = document.querySelector(".titlebar, .phonebar")?.getBoundingClientRect().bottom ?? 0;
|
||||
const bottom = document.querySelector(".tabbar")?.getBoundingClientRect().top ?? window.innerHeight;
|
||||
return {
|
||||
top: (Number.isFinite(bar) ? bar : 0) + EDGE,
|
||||
top: top + EDGE,
|
||||
left: EDGE,
|
||||
right: window.innerWidth - EDGE,
|
||||
bottom: window.innerHeight - EDGE,
|
||||
bottom: bottom - EDGE,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// What a first launch says. With nothing connected the grid is a correct and completely empty
|
||||
// calendar, which looks exactly like a calendar you have nothing in, so the one thing to do next
|
||||
// went unsaid: the panel that does it is behind a key on the desktop and an overflow sheet on a
|
||||
// phone, and neither is somewhere you look when you do not yet know it exists.
|
||||
//
|
||||
// It covers the grid rather than sitting beside it. There is nothing underneath worth reading, and
|
||||
// a note floating over an empty axis reads as a thing that failed to load.
|
||||
|
||||
import { runCommand } from "../keys/commands";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
|
||||
export function FirstRun() {
|
||||
const loaded = useAccounts((s) => s.loaded);
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
|
||||
if (!loaded || accounts.length > 0) return null;
|
||||
|
||||
return (
|
||||
<div className="first-run">
|
||||
<div className="first-run-text">
|
||||
<h2 className="first-run-title">No Google account connected</h2>
|
||||
<p className="first-run-note">
|
||||
Connect one and the calendars on it show up here. Nothing syncs until you do.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="panel-button"
|
||||
data-variant="primary"
|
||||
onClick={() => runCommand("accounts")}
|
||||
>
|
||||
Connect a Google account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FirstRun;
|
||||
@@ -31,6 +31,12 @@ import { openDetailsFor } from "./useDetails";
|
||||
const CLICK_SLOP = 3;
|
||||
const TOUCH_CLICK_SLOP = 12;
|
||||
|
||||
/**
|
||||
* How long the click a touch leaves behind is still worth waiting for. It lands a frame or so
|
||||
* after the release here, and historically as much as 300ms behind it on a mobile browser.
|
||||
*/
|
||||
const GHOST_CLICK_MS = 400;
|
||||
|
||||
interface GridEventProps {
|
||||
item: Placed;
|
||||
top: number;
|
||||
@@ -42,6 +48,34 @@ interface GridEventProps {
|
||||
onPointerDown: (e: ReactPointerEvent, item: Placed, mode: DragMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eats the click the browser sends after a touch, and only that one.
|
||||
*
|
||||
* Cancelling the pointerdown stops the mouse events that travel with it but never the click, and
|
||||
* the click is hit tested wherever the finger is when it lands, which by then is the card this
|
||||
* press has just opened. Left alone it presses whatever the card put under the finger: on a phone
|
||||
* in landscape the card is the whole stage, so a tap on a meeting opened its editor or its
|
||||
* conference link on its own.
|
||||
*/
|
||||
function swallowGhostClick(): void {
|
||||
let timer = 0;
|
||||
const done = () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("click", eat, true);
|
||||
window.removeEventListener("pointerdown", done, true);
|
||||
};
|
||||
const eat = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
done();
|
||||
};
|
||||
window.addEventListener("click", eat, true);
|
||||
// The first click after a release is the ghost, and a press that starts before it arrives means
|
||||
// it is never coming. The timer is only the backstop for a browser that sends neither.
|
||||
window.addEventListener("pointerdown", done, true);
|
||||
timer = window.setTimeout(done, GHOST_CLICK_MS);
|
||||
}
|
||||
|
||||
function edgeMode(target: EventTarget | null): DragMode {
|
||||
const el = target instanceof Element ? target.closest("[data-edge]") : null;
|
||||
const edge = el?.getAttribute("data-edge");
|
||||
@@ -103,6 +137,7 @@ export const GridEvent = memo(function GridEvent({
|
||||
if (Math.abs(event.clientX - downX) > slop) return;
|
||||
if (Math.abs(event.clientY - downY) > slop) return;
|
||||
open(element);
|
||||
if (event.pointerType !== "mouse") swallowGhostClick();
|
||||
};
|
||||
window.addEventListener("pointerup", up, true);
|
||||
window.addEventListener("pointercancel", stop, true);
|
||||
|
||||
@@ -430,12 +430,19 @@ export function GridView({ defaultCalendarId }: GridViewProps) {
|
||||
if (e.button !== 0 || gesture.current) return;
|
||||
e.stopPropagation();
|
||||
select(keyOf(item.instance));
|
||||
if (item.instance.readOnly || useGrid.getState().draft) return;
|
||||
const dayStart = startOfDay(item.startMs);
|
||||
const index = days.findIndex((d) => d === dayStart);
|
||||
if (index === -1) return;
|
||||
const { startMin, endMin } = dayMinutes(item, dayStart);
|
||||
begin(e, mode, item, index, startMin, endMin);
|
||||
// A block nothing can be done to is still something a swipe has to travel through. The press
|
||||
// is stopped here rather than on the canvas, so without this the page turn was dead over
|
||||
// every read-only event, which on a day with a couple of meetings marked busy is most of the
|
||||
// column. It gets a gesture with no long press behind it: the only thing it can become is
|
||||
// the swipe, and there is no item on it to commit a move to.
|
||||
const inert = item.instance.readOnly || useGrid.getState().draft !== null;
|
||||
if (inert && !isCoarse(e)) return;
|
||||
begin(e, mode, inert ? null : item, index, startMin, endMin);
|
||||
if (inert && gesture.current) clearPress(gesture.current);
|
||||
},
|
||||
// `begin` closes over the current layout and days, which is what a fresh gesture wants.
|
||||
[days, layout, select],
|
||||
|
||||
@@ -93,7 +93,7 @@ export function Settings() {
|
||||
|
||||
<div className="setting-row">
|
||||
<span className="setting-text">
|
||||
<span className="setting-name">Accounts</span>
|
||||
<span className="setting-name">Google accounts</span>
|
||||
<span className="setting-note">
|
||||
{accounts.length === 0
|
||||
? "No Google account connected yet."
|
||||
|
||||
@@ -30,7 +30,7 @@ const TITLES: Record<string, string> = {
|
||||
calendars: "calendars",
|
||||
"mini-month": "the calendar",
|
||||
editor: "the event",
|
||||
accounts: "accounts",
|
||||
accounts: "Google accounts",
|
||||
settings: "settings",
|
||||
shortcuts: "shortcuts",
|
||||
menu: "the menu",
|
||||
|
||||
Reference in new issue
Block a user