Skip to content

fix(support): keep the Crisp composer above the iOS keyboard - #2615

Open
abalinda wants to merge 2 commits into
mainfrom
fix/support-drawer-ios-keyboard
Open

fix(support): keep the Crisp composer above the iOS keyboard#2615
abalinda wants to merge 2 commits into
mainfrom
fix/support-drawer-ios-keyboard

Conversation

@abalinda

@abalinda abalinda commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes TASK-20947Crisp session · Discord report

The bug

Open support chat on iOS, tap the message field, and the composer disappears behind the keyboard. You can type, you just can't see what you typed.

Why

iOS never resizes the layout viewport for the software keyboard — it shrinks only the visual viewport and scrolls. SupportDrawer's panel is position: fixed; bottom: 0, and bottom: 0 means the bottom of the layout viewport: the edge the keyboard is sitting on top of. Crisp renders its composer on exactly that edge.

100vh and 100dvh are no help — dvh tracks browser chrome (the collapsing Safari toolbar), not the keyboard. CSS cannot see the keyboard at all. window.visualViewport is the only thing that can.

The fix

New useVisualViewport(enabled) hook returns the visible height and how many px of the layout viewport's bottom edge are hidden:

keyboardInset = window.innerHeight − visualViewport.height − visualViewport.offsetTop

The offsetTop term matters: iOS scrolls the focused field into view, and the panel is pinned to the layout viewport, so that scroll hides it too. Values under a 40px floor are discarded as sub-pixel/scrollbar noise — without it, a 0.8px rounding delta reads as "keyboard up" on every device. The floor sits below the ~45px accessory bar iOS shows for a hardware keyboard, which hides a composer just as well as the full keyboard.

The panel then lifts by that inset and clamps its height to what's still on screen, so the conversation is pushed down rather than off the top edge. The clamp reserves env(safe-area-inset-top) + 24px so the drag handle stays clear of the notch and a backdrop strip remains tappable; with no keyboard up that reserve is slack and 85dvh wins, leaving the resting look unchanged.

Zoom is explicitly not a keyboard: pinch shrinks the visual viewport identically, so scale > 1 stands the hook down entirely and CSS takes over. maximum-scale=1 in the root viewport rules out iOS focus auto-zoom, so a zoom is always deliberate.

Two adjacent defects, same geometry

Both are in the ticket's scope and fall out of the same lines:

  • Overflow (reported). The panel was max-h-[85vh] wrapping a fixed h-[80vh] iframe plus a ~38px drag handle. Below ~760px of viewport the max-height clipped its own content. Height is now explicit and the iframe row is flex-1 min-h-0, so it can't disagree with its parent.
  • Safe area. bottom: 0 with no padding put the composer under the home indicator on notched iPhones. Now paddingBottom: env(safe-area-inset-bottom) — dropped while the keyboard is up, since the keyboard already covers the home indicator and padding there would wedge a dead strip between composer and keys.

Scope

  • iOS Safari + installed PWA — the surfaces that use this iframe.
  • Native WebView: unaffected. isCapacitor() opens the native Crisp SDK, never this drawer's iframe (SupportDrawer/index.tsx:56-92). Its keyboard is already handled by KeyboardResize.Native in useNativePlugins.ts.
  • Listeners subscribe only while the drawer is openSupportDrawer is mounted on every route, and these events fire on every keystroke-driven scroll. State updates bail on unchanged values so momentum scroll can't cause a render storm.

Tests

src/hooks/__tests__/useVisualViewport.test.ts (10) — inset math, the offsetTop term, the noise floor, the hardware accessory bar, the zoom stand-down, reset-on-disable, unmount teardown, and browsers with no visualViewport.

SupportDrawer.test.tsx (+2) — panel is flush at bottom: 0px with no keyboard, lifts to bottom: 340px when the visual viewport drops to 460 of 800. Only bottom is assertable: jsdom's CSS parser drops both env() and min().

Local gate

Prettier ✅ · typecheck ✅ · npm test 179/179 suites, 2365 passed ✅ · npm run build

Manual QA needed

Device verification is the one thing this can't self-check — needs a real iPhone:

  1. Safari + installed PWA: open support, focus the composer, confirm it sits directly above the keyboard.
  2. Type, scroll the conversation, dismiss the keyboard — panel returns to 85dvh with no jump.
  3. Notched device, keyboard down: composer clears the home indicator.
  4. Drag-to-dismiss still works with the keyboard up.

Note (not fixed here)

crisp-proxy/page.tsx:12 says the proxy is embedded "from SupportDrawer and SupportPage" — there is no SupportPage; SupportDrawer is the only consumer. Stale comment, left alone to keep this diff surgical.


Code review round (/code-review medium)

Five findings, four applied in 5f4adfb:

Reset-on-close (HIGH) — the one that mattered. translateY(100%) resolves against the element's own height. Once the keyboard shrank the panel to ~460px it no longer travelled far enough to clear a 340px lift, so closing the drawer with the keyboard still up left an opaque sheet over the bottom third of the app. Worse, the hook unsubscribes on close, so it could never observe the keyboard leaving and self-correct. The measurement is now dropped rather than frozen.

Pinch-zoom false positive (MED). Zoom shrinks the visual viewport indistinguishably from a keyboard; scale > 1 now stands the hook down.

Panel filled the visible viewport exactly (MED). The lift and the clamp are algebraically complementary, so the panel's top landed precisely on the visible top edge — drag handle under the notch, no backdrop to tap. Hence the reserved strip.

Accessory-bar blind spot (LOW). Noise floor 80px → 40px.

Not applied — transitioning bottom/height (LOW). Suggested to smooth the ~250ms keyboard animation, currently a one-frame snap. Declined: both properties trigger layout, so a transition forces the Crisp iframe to reflow every frame for the duration instead of once. This component already carries scars from WKWebView content-process crashes under memory pressure (see the eager-mount comment at the top of SupportDrawer/index.tsx) — one reflow beats fifteen.


Screenshots — ⚠️ NONE, and this one genuinely can't have them

Not an oversight and not laziness: the states this PR changes cannot be rendered by a headless browser. They require a real iOS software keyboard shrinking the visual viewport, which desktop Chromium has no equivalent of. A 375×667 screenshot would show the resting drawer — which this PR deliberately leaves pixel-identical (with no keyboard up, the 85dvh term wins every clamp).

Faking it by stubbing window.visualViewport in a page init script would produce a picture of the stub, not of iOS, and would invite more confidence than it earns.

What stands in for visual evidence instead: the geometry is asserted numerically. SupportDrawer.test.tsx pins the panel at bottom: 0px at rest and bottom: 340px when the visual viewport drops to 460 of 800 — the exact number a screenshot would be checked against by eye.

The manual QA checklist above is therefore load-bearing, not optional. Please run it on a real device before approving.

Design notes / accepted trade-offs

Declined: transitioning bottom/height alongside transform. /code-review suggested it to smooth the ~250ms keyboard animation, which is currently a one-frame snap. Both properties trigger layout, so a transition makes the Crisp iframe reflow on every frame for the duration instead of exactly once. This component already carries scars from WKWebView content-process crashes under memory pressure — see the eager-mount comment at the top of SupportDrawer/index.tsx. One reflow beats fifteen; the snap stays.

Declined: 'use client' on the new hook (flagged nextjs-missing-use-client by code-analysis). All three sibling Crisp hooks omit it — including useCrispTokenId, which is useState/useEffect exactly like this one. Next propagates the client boundary through imports from the already-'use client' SupportDrawer, and the production build confirms it. Adding it here alone would be an inconsistent one-off.

Smell verdict: adds none. The code-analysis +6.71 painscore is the new file existing at all (0 → 6.2); its six "resolved" entries are the same pre-existing SupportDrawer findings re-reported at shifted line numbers, not fixes.

iOS never resizes the layout viewport for the software keyboard, so the
drawer's `position: fixed; bottom: 0` kept pointing at the bottom of a
window the keyboard was sitting on top of — and Crisp renders its composer
on exactly that edge. Users could open support chat but not see what they
were typing. `100vh`/`100dvh` are no help: they track browser chrome, not
the keyboard, so CSS alone cannot see this.

Measure `window.visualViewport` instead — the only thing that knows how
much screen the keyboard ate — and lift the panel by that inset while
clamping its height to what is still visible, so the conversation is
pushed down rather than off the top edge.

Two adjacent defects fall out of the same geometry and go with it:
the panel's `max-h-[85vh]` clipped its own `h-[80vh]` iframe on shorter
phones (the reported overflow), and `bottom: 0` with no safe-area padding
put the composer under the home indicator on notched iPhones.

Capacitor is unaffected — native support opens the Crisp SDK, not this iframe.

TASK-20947
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 5, 2026 5:38pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Support drawer keyboard handling

Layer / File(s) Summary
Visual viewport measurement
src/hooks/useVisualViewport.ts, src/hooks/__tests__/useVisualViewport.test.ts
Adds useVisualViewport with keyboard inset calculation, viewport event subscriptions, cleanup, pinch-zoom handling, and unsupported-environment handling.
Keyboard-aware drawer layout
src/components/Global/SupportDrawer/index.tsx, src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
Positions the drawer above the iOS keyboard, constrains its height, adjusts safe-area padding, resizes the iframe container, and validates resize behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant visualViewport
  participant useVisualViewport
  participant SupportDrawer
  visualViewport->>useVisualViewport: Emit resize or scroll event
  useVisualViewport->>SupportDrawer: Return viewport height and keyboard inset
  SupportDrawer->>SupportDrawer: Apply bottom offset and constrained height
Loading

Possibly related PRs

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: keeping the Crisp composer above the iOS keyboard.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/support-drawer-ios-keyboard

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2368 ran, 0 failed, 0 skipped, 39.2s

📊 Coverage (unit)

metric %
statements 63.0%
branches 46.9%
functions 53.0%
lines 63.5%
⏱ 10 slowest test cases
time test
3.6s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a stored balance that has no timestamp (legacy install)
0.2s src/utils/__tests__/demo-balance.test.ts › starts at the full balance on a fresh install and stamps a timestamp
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useVisualViewport.ts`:
- Around line 31-33: Reset the hook’s measurement state when useVisualViewport’s
enabled flag is false, before returning from the effect, so keyboardInset and
visibleHeight cannot persist across drawer closes. Preserve the existing
viewport setup and measurement behavior when enabled, and add a regression test
covering close, viewport restoration, and reopen.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 53650c07-70d4-4176-98c3-bf6d4bd5ac6e

📥 Commits

Reviewing files that changed from the base of the PR and between f6183f8 and ce80d5f.

📒 Files selected for processing (4)
  • src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
  • src/components/Global/SupportDrawer/index.tsx
  • src/hooks/__tests__/useVisualViewport.test.ts
  • src/hooks/useVisualViewport.ts

Comment thread src/hooks/useVisualViewport.ts Outdated
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6346.7 → 6353.41 (+6.71)
Findings: +4 net (+10 new, -6 resolved)

🆕 New findings (10)

  • critical complexity — src/components/Global/SupportDrawer/index.tsx — CC 53, MI 61.9, SLOC 137
  • medium high-mdd — src/components/Global/SupportDrawer/index.tsx:19 — SupportDrawer: MDD 57.9 (uses across many lines from declarations)
  • medium method-complexity — src/components/Global/SupportDrawer/index.tsx:19 — CC 16 SLOC 60
  • medium react-effect-derives-state — src/components/Global/SupportDrawer/index.tsx:47 — small useEffect that only sets state from deps
  • medium react-effect-derives-state — src/components/Global/SupportDrawer/index.tsx:129 — useEffect with empty deps + setState — derived state anti-pattern
  • medium nextjs-missing-use-client — src/hooks/useVisualViewport.ts:1 — Hooks used without use client directive
  • low high-dlt — src/components/Global/SupportDrawer/index.tsx:19 — SupportDrawer: DLT 26 (calls 26 distinct functions — high context load)
  • low high-mdd — src/hooks/useVisualViewport.ts:32 — useVisualViewport: MDD 19.2 (uses across many lines from declarations)
  • low high-mdd — src/hooks/useVisualViewport.ts:35 — : MDD 18.6 (uses across many lines from declarations)
  • low high-mdd — src/components/Global/SupportDrawer/index.tsx:129 — : MDD 11.5 (uses across many lines from declarations)

✅ Resolved (6)

  • src/components/Global/SupportDrawer/index.tsx — CC 51, MI 62.6, SLOC 129
  • src/components/Global/SupportDrawer/index.tsx:15 — SupportDrawer: MDD 52.7 (uses across many lines from declarations)
  • src/components/Global/SupportDrawer/index.tsx:38 — small useEffect that only sets state from deps
  • src/components/Global/SupportDrawer/index.tsx:120 — useEffect with empty deps + setState — derived state anti-pattern
  • src/components/Global/SupportDrawer/index.tsx:15 — SupportDrawer: DLT 25 (calls 25 distinct functions — high context load)
  • src/components/Global/SupportDrawer/index.tsx:120 — : MDD 11.5 (uses across many lines from declarations)

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/useVisualViewport.ts 0.0 6.2 +6.2

Reset-on-close is the serious one. `translateY(100%)` resolves against the
element's OWN height, so once the keyboard shrank the panel to ~460px it no
longer travelled far enough to clear a 340px lift — closing the drawer with
the keyboard still up left an opaque sheet over the bottom third of the app.
The hook unsubscribes on close, so it could never see the keyboard leave and
correct itself. Drop the measurement instead of freezing it.

Also:
- Stand down while pinch-zoomed. Zoom shrinks the visual viewport exactly
  like a keyboard does; a zoomed reader isn't typing, and adjusting for them
  just makes the panel jump. `maximum-scale=1` rules out iOS focus auto-zoom,
  so scale > 1 is always deliberate.
- Lower the noise floor 80px → 40px. The old floor cleared scrollbar and
  sub-pixel noise but also swallowed the ~45px accessory bar iOS shows for a
  hardware keyboard — which hides a composer just as well.
- Reserve a strip above the panel. The clamp made the panel exactly fill the
  visible viewport, putting the drag handle under the notch and leaving no
  backdrop to tap. Slack when no keyboard is up, so the resting look is
  unchanged.

Tests restore window.innerHeight/visualViewport so later blocks in the file
don't inherit a fake viewport.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant