-
Notifications
You must be signed in to change notification settings - Fork 7
Messages PWA Phase 1 — mobile UX + install prompt #238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
7eca7c7
docs: Messages PWA Phase 1 spec — mobile UX + install prompt
jaylfc 7656931
docs: Messages PWA Phase 1 implementation plan (11 TDD tasks)
jaylfc 2ca4d85
feat(desktop): use-visual-viewport hook for keyboard-aware mobile UI
jaylfc ce359ba
feat(desktop): BottomSheet shell primitive with drag-to-dismiss
jaylfc c7adf83
feat(desktop): InstallPromptBanner with 30-day dismissal suppression
jaylfc f09f6d8
feat(desktop): ThreadPanel isFullscreen prop for mobile takeover
jaylfc e8d655c
feat(desktop): mobile thread takeover when viewport is < 768px
jaylfc cca45c1
feat(desktop): mobile overflow menu renders in bottom sheet
jaylfc 7a6a7dc
feat(desktop): keyboard-aware composer padding on mobile
jaylfc 29db9bf
feat(desktop): mount InstallPromptBanner in ChatStandalone
jaylfc 4805437
feat(pwa): iOS splash startup image for taOS talk PWA
jaylfc 6043d35
build: rebuild desktop bundle for Messages PWA Phase 1
jaylfc f0c71f0
test(e2e): Messages PWA mobile viewport — thread takeover, bottom she…
jaylfc 9800c77
fix(pwa): BottomSheet focus trap + always-visible hover actions on mo…
jaylfc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { renderHook, act } from "@testing-library/react"; | ||
| import { useVisualViewport } from "../use-visual-viewport"; | ||
|
|
||
| describe("useVisualViewport", () => { | ||
| const listeners = new Map<string, Set<() => void>>(); | ||
| let vv: { height: number; offsetTop: number; addEventListener: (t: string, l: () => void) => void; removeEventListener: (t: string, l: () => void) => void }; | ||
|
|
||
| beforeEach(() => { | ||
| listeners.clear(); | ||
| vv = { | ||
| height: 800, | ||
| offsetTop: 0, | ||
| addEventListener: (t, l) => { | ||
| if (!listeners.has(t)) listeners.set(t, new Set()); | ||
| listeners.get(t)!.add(l); | ||
| }, | ||
| removeEventListener: (t, l) => listeners.get(t)?.delete(l), | ||
| }; | ||
| Object.defineProperty(window, "visualViewport", { value: vv, configurable: true }); | ||
| Object.defineProperty(window, "innerHeight", { value: 800, configurable: true }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| // @ts-expect-error test cleanup | ||
| delete window.visualViewport; | ||
| }); | ||
|
|
||
| it("returns height + keyboardInset=0 when viewport matches window", () => { | ||
| const { result } = renderHook(() => useVisualViewport()); | ||
| expect(result.current.height).toBe(800); | ||
| expect(result.current.keyboardInset).toBe(0); | ||
| }); | ||
|
|
||
| it("computes keyboardInset when viewport shrinks (keyboard open)", () => { | ||
| const { result } = renderHook(() => useVisualViewport()); | ||
| act(() => { | ||
| vv.height = 500; | ||
| listeners.get("resize")?.forEach((l) => l()); | ||
| }); | ||
| expect(result.current.keyboardInset).toBe(300); | ||
| }); | ||
|
|
||
| it("returns fallback when visualViewport is undefined", () => { | ||
| // @ts-expect-error delete VV | ||
| delete window.visualViewport; | ||
| Object.defineProperty(window, "innerHeight", { value: 600, configurable: true }); | ||
| const { result } = renderHook(() => useVisualViewport()); | ||
| expect(result.current.height).toBe(600); | ||
| expect(result.current.keyboardInset).toBe(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| export interface VisualViewportState { | ||
| height: number; | ||
| keyboardInset: number; | ||
| } | ||
|
|
||
| function read(): VisualViewportState { | ||
| if (typeof window === "undefined") return { height: 0, keyboardInset: 0 }; | ||
| const vv = window.visualViewport; | ||
| if (!vv) return { height: window.innerHeight, keyboardInset: 0 }; | ||
| const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); | ||
| return { height: vv.height, keyboardInset: inset }; | ||
| } | ||
|
|
||
| export function useVisualViewport(): VisualViewportState { | ||
| const [state, setState] = useState<VisualViewportState>(read); | ||
|
|
||
| useEffect(() => { | ||
| if (typeof window === "undefined") return; | ||
| const vv = window.visualViewport; | ||
| if (!vv) return; | ||
| const update = () => setState(read()); | ||
| vv.addEventListener("resize", update); | ||
| vv.addEventListener("scroll", update); | ||
| return () => { | ||
| vv.removeEventListener("resize", update); | ||
| vv.removeEventListener("scroll", update); | ||
| }; | ||
| }, []); | ||
|
|
||
| return state; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { useEffect, useRef, useState } from "react"; | ||
|
|
||
| export interface BottomSheetProps { | ||
| open: boolean; | ||
| onClose: () => void; | ||
| children: React.ReactNode; | ||
| labelledBy?: string; | ||
| dragHandle?: boolean; | ||
| } | ||
|
|
||
| const DISMISS_THRESHOLD_PX = 80; | ||
|
|
||
| export function BottomSheet({ | ||
| open, onClose, children, labelledBy, dragHandle = true, | ||
| }: BottomSheetProps) { | ||
| const sheetRef = useRef<HTMLDivElement>(null); | ||
| const [dragY, setDragY] = useState(0); | ||
|
|
||
| useEffect(() => { | ||
| if (!open) return; | ||
| const onKey = (e: KeyboardEvent) => { | ||
| if (e.key === "Escape") { e.preventDefault(); onClose(); } | ||
| }; | ||
| document.addEventListener("keydown", onKey); | ||
| return () => document.removeEventListener("keydown", onKey); | ||
| }, [open, onClose]); | ||
|
|
||
| useEffect(() => { | ||
| if (!open) return; | ||
| const previouslyFocused = document.activeElement as HTMLElement | null; | ||
| const first = sheetRef.current?.querySelector<HTMLElement>( | ||
| 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' | ||
| ); | ||
| first?.focus(); | ||
| return () => { | ||
| previouslyFocused?.focus?.(); | ||
| }; | ||
| }, [open]); | ||
|
|
||
| // Tab-cycling focus trap: keep focus inside the sheet while open. | ||
| useEffect(() => { | ||
| if (!open) return; | ||
| const onKeyDown = (e: KeyboardEvent) => { | ||
| if (e.key !== "Tab") return; | ||
| const root = sheetRef.current; | ||
| if (!root) return; | ||
| const focusables = Array.from( | ||
| root.querySelectorAll<HTMLElement>( | ||
| 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' | ||
| ) | ||
| ).filter((el) => !el.hasAttribute("disabled")); | ||
| if (focusables.length === 0) return; | ||
| const first = focusables[0]!; | ||
| const last = focusables[focusables.length - 1]!; | ||
| const active = document.activeElement as HTMLElement | null; | ||
| if (e.shiftKey && active === first) { | ||
| e.preventDefault(); | ||
| last.focus(); | ||
| } else if (!e.shiftKey && active === last) { | ||
| e.preventDefault(); | ||
| first.focus(); | ||
| } | ||
| }; | ||
| document.addEventListener("keydown", onKeyDown); | ||
| return () => document.removeEventListener("keydown", onKeyDown); | ||
| }, [open]); | ||
|
|
||
| if (!open) return null; | ||
|
|
||
| const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { | ||
| const startY = e.clientY; | ||
| const el = e.currentTarget; | ||
| el.setPointerCapture(e.pointerId); | ||
| const onMove = (ev: PointerEvent) => { | ||
| const dy = Math.max(0, ev.clientY - startY); | ||
| setDragY(dy); | ||
| }; | ||
| const onUp = (ev: PointerEvent) => { | ||
| const dy = Math.max(0, ev.clientY - startY); | ||
| el.releasePointerCapture(e.pointerId); | ||
| el.removeEventListener("pointermove", onMove); | ||
| el.removeEventListener("pointerup", onUp); | ||
| el.removeEventListener("pointercancel", onCancel); | ||
| if (dy > DISMISS_THRESHOLD_PX) onClose(); | ||
| setDragY(0); | ||
| }; | ||
| const onCancel = () => { | ||
| el.releasePointerCapture(e.pointerId); | ||
| el.removeEventListener("pointermove", onMove); | ||
| el.removeEventListener("pointerup", onUp); | ||
| el.removeEventListener("pointercancel", onCancel); | ||
| setDragY(0); | ||
| }; | ||
| el.addEventListener("pointermove", onMove); | ||
| el.addEventListener("pointerup", onUp); | ||
| el.addEventListener("pointercancel", onCancel); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <div | ||
| data-testid="bottom-sheet-backdrop" | ||
| className="fixed inset-0 z-50 bg-black/60" | ||
| onClick={onClose} | ||
| /> | ||
| <div | ||
| ref={sheetRef} | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-labelledby={labelledBy} | ||
| className="fixed bottom-0 inset-x-0 z-50 bg-shell-surface rounded-t-xl border-t border-white/10 shadow-2xl max-h-[85vh] overflow-y-auto" | ||
| style={{ | ||
| paddingBottom: "env(safe-area-inset-bottom, 0px)", | ||
| transform: `translateY(${dragY}px)`, | ||
| transition: dragY === 0 ? "transform 0.2s ease-out" : "none", | ||
| }} | ||
| > | ||
| {dragHandle && ( | ||
| <div | ||
| data-testid="bottom-sheet-handle" | ||
| onPointerDown={onPointerDown} | ||
| className="flex justify-center py-2 cursor-grab active:cursor-grabbing touch-none" | ||
| > | ||
| <div className="w-10 h-1 bg-white/20 rounded-full" /> | ||
| </div> | ||
| )} | ||
| {children} | ||
| </div> | ||
| </> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.