Capacitor Mobile App
Loquent ships as a native iOS and Android app using Capacitor 7. The native shell is a thin WebView wrapper that loads the production web app from the server — no code is bundled into the binary.
Architecture
Section titled “Architecture”The mobile shell runs in remote server mode. On launch the WebView navigates to the configured server URL (default https://app.loquent.io). Auth, routing, API calls, and WebSocket connections work identically to a browser session.
┌─────────────────────────────┐│ Native Shell (Capacitor) ││ ┌───────────────────────┐ ││ │ WKWebView / WebView │ ││ │ → app.loquent.io │ ││ │ → WASM + Dioxus │ ││ └───────────────────────┘ ││ Plugins: Push, Haptics, ││ Keyboard, Badge, Network │└─────────────────────────────┘If the server is unreachable, the WebView renders mobile/public/offline.html as a fallback.
JS Bridge
Section titled “JS Bridge”mobile/capacitor-bridge.js exposes window.CapacitorBridge — a thin wrapper over Capacitor plugin APIs. Every method degrades to a no-op when window.Capacitor is undefined, so the same WASM bundle runs in both browser and native shell.
The bridge is inlined into the Dioxus client root:
include_str!("../../../mobile/capacitor-bridge.js")Bridge methods
Section titled “Bridge methods”| Method | Plugin | Purpose |
|---|---|---|
isNative() | Core | Returns true inside Capacitor |
getPlatform() | Core | "ios", "android", or "web" |
registerPushWithListeners(onReg, onErr) | PushNotifications | Atomic registration + listener attach |
hapticImpact(style) | Haptics | Tactile feedback (Light, Medium, Heavy) |
setBadge(count) / clearBadge() | Badge | App icon badge count |
hideSplash() | SplashScreen | Dismiss native splash after first render |
onAppStateChange(cb) | App | Foreground/background transitions |
onAppUrlOpen(cb) | App | Deep link handling |
Rust Integration
Section titled “Rust Integration”Three Rust modules in src/shared/capacitor/ consume the bridge:
| Module | Role |
|---|---|
bridge.rs | is_native(), get_platform(), hide_splash_screen() via js_sys::Reflect |
lifecycle.rs | Wires appStateChange and appUrlOpen to reconnect signal + router |
push.rs | Device registration, token POST, notification action routing |
Client components
Section titled “Client components”Two components mount inside PrivateLayout (post-auth only):
MobileLifecycleMount— listens for foreground events and bumpsRealtimeContext.force_reconnectso the WebSocket reconnects within ~1s instead of waiting for the heartbeat timeout. Also handles deep links fromappUrlOpenandgetLaunchUrl.PushRegistrationMount— generates a stable device ID (localStoragekeyloquent.device_id), requests permission, registers with APNs/FCM, and POSTs the token to the backend.
Push Notifications
Section titled “Push Notifications”Registration flow
Section titled “Registration flow”- Generate or read
loquent.device_id(UUIDv4 in localStorage) PushNotifications.requestPermissions()PushNotifications.register()(atomic with listeners)POST /api/push-notifications/tokenswith{ token, platform, device_id }- Server upserts on
(user_id, device_id)
Notification tap routing
Section titled “Notification tap routing”Payloads include a data.url field (FCM) or custom payload field (APNs). On tap, MobileLifecycleMount parses the path and calls nav.push(Route::from_str(&path)) to navigate to the relevant view.
Credentials
Section titled “Credentials”Configured in the admin panel (/admin/system → Push Notifications):
- APNs: Auth Key (.p8), Key ID, Team ID
B6V2J755DQ, Bundle IDcom.loquent-comm.app - FCM: Service Account JSON uploaded to the admin panel
Keyboard Handling
Section titled “Keyboard Handling”The bridge attaches listeners on load:
keyboardWillShow→ sets--kb-hCSS variable on<html>to the keyboard heightkeyboardWillHide→ resets--kb-hto0pxkeyboardDidShow→ scrolls message feeds (#communication-feed,#assistant-messages) to bottomfocusin→ scrolls focused inputs into view after 250ms delay
Dioxus layout components use calc(100dvh - var(--kb-h)) to shrink in sync with the keyboard.
Deep Linking
Section titled “Deep Linking”The app handles universal links (iOS) and app links (Android). URLs matching app.loquent.io/* open directly in the native app. Configuration:
- iOS: Associated Domains entitlement (
applinks:app.loquent.io) - Android:
intent-filterinAndroidManifest.xmlwithautoVerify="true"+assetlinks.jsonserved by the backend (requiresMOBILE_ANDROID_SHA256_FINGERPRINTSenv var)
Configuration
Section titled “Configuration”The server URL is read from mobile/.env (gitignored):
CAPACITOR_SERVER_URL=https://app.loquent.ioOverride for local dev or PR previews — set the URL and run pnpm run sync.
Project Structure
Section titled “Project Structure”mobile/├── android/ # Android Studio project (tracked)├── ios/ # Xcode project (tracked, Pods/ ignored)├── public/offline.html # Offline fallback├── capacitor.config.ts # Server URL, plugin config├── capacitor-bridge.js # JS bridge for Rust/WASM├── package.json # Capacitor 7 + plugins└── .env # Per-dev server URL (gitignored)Plugins
Section titled “Plugins”| Package | Version | Purpose |
|---|---|---|
@capacitor/app | ^7.0.0 | Lifecycle, deep links |
@capacitor/push-notifications | ^7.0.0 | APNs + FCM |
@capacitor/haptics | ^7.0.0 | Tactile feedback |
@capacitor/keyboard | ^7.0.0 | Keyboard events |
@capacitor/network | ^7.0.0 | Connectivity status |
@capacitor/splash-screen | ^7.0.0 | Launch splash control |
@capacitor/status-bar | ^7.0.0 | Status bar styling |
@capawesome/capacitor-badge | ^7.0.0 | App icon badge |