Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .cursor/rules/mobile-drawer-menus.mdc
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,10 @@ Standalone single-field edit (no stack): `MobileFormDrawer`.

- **Back** from a field screen: always dismiss via `MobileDrawerFieldView` (default) or `useMobileDrawerLeave()` so the previous view does not resize against a shifting visual viewport.
- **Async save then navigate**: `dismissKeyboardOnDone={false}` on `MobileDrawerFieldView`, then `leave(onSaved)` after success (`useMobileDrawerLeave`).
- **Autofocus**: built into `MobileDrawerFieldView` (first `input`/`textarea`). Prefer that over manual focus + timeouts.
- **Autofocus / iOS keyboard**: Safari only opens the keyboard when focus runs in the **same user-gesture turn** as the tap. The kit handles this when you:
- Drill into a `keyboardView` via `stage.goToView` (flushSync + `MobileDrawerFieldView` layout-effect focus), or
- Open a controlled field drawer with `runWithMobileDrawerOpenSync(() => setOpen(true))` then `focusMobileDrawerInput(ref.current)` (see `document-breadcrumb.tsx`).
Do **not** rely on `useEffect`, `requestAnimationFrame`, or animation-complete handlers to open the keyboard.

## Do not

Expand All@@ -49,6 +52,11 @@ mainMeasureRef.current ?? stageRef.current;

// ❌ Navigate back while keyboard is up without wait/blur
onBack={stage.returnToView("profile")};

// ❌ Deferred focus — focuses the input on device but does not open the keyboard
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, [open]);
```

```tsx
Expand All@@ -59,6 +67,10 @@ useMobileDrawerStage({ view, setView, mainView: "main", keyboardView: KEYBOARD_V
<MobileDrawerFieldView title="…" onBack={() => stage.returnToView("profile")} onDone={…}>
<Input className={MOBILE_DRAWER_FIELD_INPUT_CLASS} />
</MobileDrawerFieldView>

// ✅ Controlled single-field open (standalone drawer)
runWithMobileDrawerOpenSync(() => setOpen(true));
focusMobileDrawerInput(inputRef.current);
```

## Wiring checklist
Expand Down
3 changes: 2 additions & 1 deletion src/app/_components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@ import { MobileActionGroup } from "./mobile-action-rows";
import { useCommandMenuStore } from "~/hooks/use-command-menu";
import { useUserProfile } from "~/hooks/use-user-profile";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
import { randomUUID } from "~/lib/utils";

interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
initialDocuments: { id: string; name: string }[];
Expand DownExpand Up@@ -86,7 +87,7 @@ export function AppSidebar({ initialDocuments, ...props }: AppSidebarProps) {

// Instant document creation with optimistic sidebar update
const handleCreateDocument = React.useCallback(() => {
const newId = crypto.randomUUID();
const newId = randomUUID();

// Mark as new for the document page
markDocumentAsNew(newId);
Expand Down
3 changes: 2 additions & 1 deletion src/app/_components/command-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import { DialogTitle } from "./dialog";
import { useCommandMenuStore } from "~/hooks/use-command-menu";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
import { useTheme } from "next-themes";
import { randomUUID } from "~/lib/utils";

export function CommandMenu() {
const [search, setSearch] = useState("");
Expand All@@ -30,7 +31,7 @@ export function CommandMenu() {

// Instant document creation - navigate immediately with a new UUID
const handleCreateDocument = useCallback(() => {
const newId = crypto.randomUUID();
const newId = randomUUID();
markDocumentAsNew(newId);
router.push(`/documents/${newId}`);
closeAll();
Expand Down
13 changes: 11 additions & 2 deletions src/app/_components/document-breadcrumb.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,11 @@ import {
PopoverTrigger,
} from "~/app/_components/popover";
import { Input } from "~/app/_components/input";
import { MobileFormDrawer } from "~/app/_components/mobile-drawer";
import {
focusMobileDrawerInput,
MobileFormDrawer,
runWithMobileDrawerOpenSync,
} from "~/app/_components/mobile-drawer";
import { useIsMobile } from "~/hooks/use-mobile";

export function DocumentBreadcrumb() {
Expand DownExpand Up@@ -156,7 +160,12 @@ export function DocumentBreadcrumb() {
const openTitleEditor = React.useCallback(() => {
setEditingName(document?.document?.name ?? "Untitled");
if (isMobile) {
setDrawerOpen(true);
runWithMobileDrawerOpenSync(() => {
setDrawerOpen(true);
});
// Controlled open can't recover the gesture after the fact — focus here
// (FieldView layout-effect also runs during the sync mount).
focusMobileDrawerInput(titleInputRef.current);
} else {
setPopoverOpen(true);
}
Expand Down
1 change: 1 addition & 0 deletions src/app/_components/mobile-drawer/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,6 @@ export {
applyMobileDrawerKeyboardInset,
focusMobileDrawerInput,
resetMobileDrawerKeyboardStyles,
runWithMobileDrawerOpenSync,
waitForMobileDrawerKeyboardDismiss,
} from "./utils";
25 changes: 12 additions & 13 deletions src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"use client";

import {
useEffect,
useLayoutEffect,
useRef,
type FormEvent,
type ReactNode,
Expand DownExpand Up@@ -75,20 +75,19 @@ export function MobileDrawerFieldView({
const rootRef = useRef<HTMLElement | null>(null);
const leave = useMobileDrawerLeave();

useEffect(() => {
// useLayoutEffect (not useEffect/rAF): iOS Safari only opens the keyboard when
// focus runs in the same turn as the user gesture. Pair with flushSync when
// mounting this view (see goToView / runWithMobileDrawerOpenSync).
useLayoutEffect(() => {
if (!autoFocus) return;

const frame = requestAnimationFrame(() => {
const input = rootRef.current?.querySelector("input, textarea");
if (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement
) {
focusMobileDrawerInput(input);
}
});

return () => cancelAnimationFrame(frame);
const input = rootRef.current?.querySelector("input, textarea");
if (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement
) {
focusMobileDrawerInput(input);
}
}, [autoFocus]);

const handleBack = () => {
Expand Down
18 changes: 15 additions & 3 deletions src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";

import {
MOBILE_DRAWER_KEYBOARD_CLEARANCE_PX,
Expand DownExpand Up@@ -75,10 +76,21 @@ export function useMobileDrawerStage<T extends string>({

const goToView = useCallback(
(next: T, nextDirection: number) => {
setDirection(nextDirection);
setView(next);
const apply = () => {
setDirection(nextDirection);
setView(next);
};

// Keyboard fields must mount inside the tap so FieldView's layout-effect
// focus can open the iOS software keyboard.
if (isKeyboardView(next)) {
flushSync(apply);
return;
}

apply();
},
[setView],
[isKeyboardView, setView],
);

const measureMainStage = useCallback(() => {
Expand Down
11 changes: 11 additions & 0 deletions src/app/_components/mobile-drawer/utils.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
import { flushSync } from "react-dom";

import { MOBILE_DRAWER_KEYBOARD_SHELL_EXTRA_PX } from "./constants";

/**
* Run state updates that mount a keyboard field inside the current user
* gesture. Required for iOS Safari to open the software keyboard — deferred
* focus (useEffect / rAF / animation complete) will not.
*/
export function runWithMobileDrawerOpenSync(update: () => void) {
flushSync(update);
}

/** Clear Vaul inline styles applied while the keyboard was open. */
export function resetMobileDrawerKeyboardStyles() {
const drawer = document.querySelector("[data-vaul-drawer]");
Expand Down
3 changes: 2 additions & 1 deletion src/app/_components/welcome-client.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { Button } from "./ui/button";
import { useCallback } from "react";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
import { randomUUID } from "~/lib/utils";

interface WelcomeClientProps {
userName: string;
Expand All@@ -14,7 +15,7 @@ export function WelcomeClient({ userName }: WelcomeClientProps) {

// Instant document creation - navigate immediately with a new UUID
const handleGetStarted = useCallback(() => {
const newId = crypto.randomUUID();
const newId = randomUUID();
markDocumentAsNew(newId);
router.push(`/documents/${newId}`);
}, [router]);
Expand Down
3 changes: 2 additions & 1 deletion src/app/documents/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,13 +6,14 @@ import { useRouter } from "next/navigation";
import { MotionFade } from "~/app/_components/motion-fade";
import { useCallback } from "react";
import { markDocumentAsNew } from "~/hooks/use-new-document-flag";
import { randomUUID } from "~/lib/utils";

export default function DocumentsPage() {
const router = useRouter();

// Instant document creation - navigate immediately with a new UUID
const handleCreateDocument = useCallback(() => {
const newId = crypto.randomUUID();
const newId = randomUUID();
markDocumentAsNew(newId);
router.push(`/documents/${newId}`);
}, [router]);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/use-collaborative-doc-crdt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
import { api } from "~/trpc/react";
import { randomUUID } from "~/lib/utils";

interface UseCollaborativeDocCrdtOptions {
documentId: string;
Expand All@@ -22,7 +23,7 @@ interface UseCollaborativeDocCrdtResult {
/* -------------------------------- Helpers -------------------------------- */

function generateClientId(): string {
return crypto.randomUUID();
return randomUUID();
}

function base64ToUint8Array(base64: string): Uint8Array {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/avatar-schema.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { randomUUID } from "~/lib/utils";

/**
* Shared by the avatar picker, the upload helper, and the tRPC user router.
Expand DownExpand Up@@ -55,7 +56,7 @@ export function validateAvatarFile(file: File): string | null {
*/
export function buildAvatarPath(userId: string, file: File): string {
const extension = EXTENSION_BY_TYPE[file.type] ?? "png";
return `${userId}/${crypto.randomUUID()}.${extension}`;
return `${userId}/${randomUUID()}.${extension}`;
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/lib/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

/**
* UUID that works outside secure contexts (e.g. http://LAN-IP on a phone).
* `crypto.randomUUID` is HTTPS/localhost-only; `getRandomValues` is not.
*/
export function randomUUID(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID()
}

const bytes = new Uint8Array(16)
globalThis.crypto.getRandomValues(bytes)
// RFC 4122 version 4
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80

const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}