diff --git a/ios/App/Podfile b/ios/App/Podfile
index 297f568..11ba037 100644
--- a/ios/App/Podfile
+++ b/ios/App/Podfile
@@ -20,6 +20,7 @@ def capacitor_pods
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'ForumCapacitorNativeInput', :path => '../../native-input'
+ pod 'ForumCapacitorNativeSidebar', :path => '../../native-sidebar'
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
end
diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock
index a1a7e94..e71ad86 100644
--- a/ios/App/Podfile.lock
+++ b/ios/App/Podfile.lock
@@ -24,6 +24,8 @@ PODS:
- Capacitor
- ForumCapacitorNativeInput (1.0.0):
- Capacitor
+ - ForumCapacitorNativeSidebar (1.0.0):
+ - Capacitor
- Google-Maps-iOS-Utils (5.0.0):
- GoogleMaps (~> 8.0)
- GoogleMaps (8.4.0):
@@ -48,6 +50,7 @@ DEPENDENCIES:
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- ForumCapacitorNativeInput (from `../../native-input`)
+ - ForumCapacitorNativeSidebar (from `../../native-sidebar`)
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
SPEC REPOS:
@@ -80,6 +83,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/splash-screen"
ForumCapacitorNativeInput:
:path: "../../native-input"
+ ForumCapacitorNativeSidebar:
+ :path: "../../native-sidebar"
NotnotsamuelCapacitorSwipeBack:
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
@@ -95,12 +100,13 @@ SPEC CHECKSUMS:
CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
+ ForumCapacitorNativeSidebar: 2b93b6d0c8ae25a08437fe98bccfc528d8f9626a
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
-PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d
+PODFILE CHECKSUM: 5005cc1c35ad0396f6966ccf4d0c4d4790dac751
COCOAPODS: 1.16.2
diff --git a/native-sidebar/ForumCapacitorNativeSidebar.podspec b/native-sidebar/ForumCapacitorNativeSidebar.podspec
new file mode 100644
index 0000000..6ce0ba1
--- /dev/null
+++ b/native-sidebar/ForumCapacitorNativeSidebar.podspec
@@ -0,0 +1,17 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'ForumCapacitorNativeSidebar'
+ s.version = package['version']
+ s.summary = package['description']
+ s.license = package['license']
+ s.homepage = 'https://frm.so'
+ s.author = package['author']
+ s.source = { :git => 'https://frm.so', :tag => s.version.to_s }
+ s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
+ s.ios.deployment_target = '14.0'
+ s.dependency 'Capacitor'
+ s.swift_version = '5.1'
+end
diff --git a/native-sidebar/ios/Plugin/Info.plist b/native-sidebar/ios/Plugin/Info.plist
new file mode 100644
index 0000000..1007fd9
--- /dev/null
+++ b/native-sidebar/ios/Plugin/Info.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSPrincipalClass
+
+
+
diff --git a/native-sidebar/ios/Plugin/NativeSidebarPlugin.m b/native-sidebar/ios/Plugin/NativeSidebarPlugin.m
new file mode 100644
index 0000000..69e9901
--- /dev/null
+++ b/native-sidebar/ios/Plugin/NativeSidebarPlugin.m
@@ -0,0 +1,13 @@
+#import
+#import
+
+// Define the plugin using the CAP_PLUGIN Macro, and
+// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
+CAP_PLUGIN(NativeSidebarPlugin, "NativeSidebar",
+ CAP_PLUGIN_METHOD(setData, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(open, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(close, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise);
+ CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise);
+)
diff --git a/native-sidebar/ios/Plugin/NativeSidebarPlugin.swift b/native-sidebar/ios/Plugin/NativeSidebarPlugin.swift
new file mode 100644
index 0000000..956b4ad
--- /dev/null
+++ b/native-sidebar/ios/Plugin/NativeSidebarPlugin.swift
@@ -0,0 +1,88 @@
+import Foundation
+import Capacitor
+
+/// Hosts a native left-edge drawer (the "sidebar") that floats above the
+/// Capacitor web view. The drawer's *content* is a second, transparent
+/// WKWebView that renders the existing `Sidebar.js` from data pushed in via
+/// `setData`. Taps inside that content web view are forwarded back to the main
+/// app as an `action` event, mirroring how the NativeInput plugin works.
+@objc(NativeSidebarPlugin)
+public class NativeSidebarPlugin: CAPPlugin {
+
+ private var drawer: SidebarDrawer?
+
+ // MARK: Helpers
+
+ private func hostView() -> UIView? {
+ // Pin the drawer as a sibling of the web view inside the same parent so
+ // it can float above the web content (same pattern as NativeInput).
+ return webView?.superview ?? webView
+ }
+
+ private func ensureDrawer() -> SidebarDrawer? {
+ if let drawer = drawer { return drawer }
+ guard let host = hostView() else { return nil }
+ let drawer = SidebarDrawer(hostView: host, mainWebView: webView, parentVC: bridge?.viewController)
+ drawer.onAction = { [weak self] data in
+ self?.notifyListeners("action", data: data)
+ }
+ drawer.onStateChange = { [weak self] open in
+ self?.notifyListeners("stateChange", data: ["open": open])
+ }
+ drawer.attach()
+ self.drawer = drawer
+ return drawer
+ }
+
+ // MARK: Plugin methods
+
+ @objc func setData(_ call: CAPPluginCall) {
+ // Forward the whole options object (minus Capacitor's callbackId) to the
+ // content web view as the sidebar payload.
+ var payload = (call.options as? [String: Any]) ?? [:]
+ payload.removeValue(forKey: "callbackId")
+ DispatchQueue.main.async {
+ guard let drawer = self.ensureDrawer() else { call.reject("No host view"); return }
+ drawer.setData(payload)
+ call.resolve()
+ }
+ }
+
+ @objc func setTheme(_ call: CAPPluginCall) {
+ let theme = call.getString("theme") ?? ""
+ DispatchQueue.main.async {
+ self.drawer?.setTheme(theme)
+ call.resolve()
+ }
+ }
+
+ @objc func open(_ call: CAPPluginCall) {
+ DispatchQueue.main.async {
+ self.ensureDrawer()?.open()
+ call.resolve()
+ }
+ }
+
+ @objc func close(_ call: CAPPluginCall) {
+ DispatchQueue.main.async {
+ self.drawer?.close()
+ call.resolve()
+ }
+ }
+
+ @objc func setEnabled(_ call: CAPPluginCall) {
+ let enabled = call.getBool("enabled") ?? true
+ DispatchQueue.main.async {
+ self.drawer?.setEnabled(enabled)
+ call.resolve()
+ }
+ }
+
+ @objc func dismiss(_ call: CAPPluginCall) {
+ DispatchQueue.main.async {
+ self.drawer?.removeFromHost()
+ self.drawer = nil
+ call.resolve()
+ }
+ }
+}
diff --git a/native-sidebar/ios/Plugin/SidebarDrawer.swift b/native-sidebar/ios/Plugin/SidebarDrawer.swift
new file mode 100644
index 0000000..271cd10
--- /dev/null
+++ b/native-sidebar/ios/Plugin/SidebarDrawer.swift
@@ -0,0 +1,365 @@
+import UIKit
+import WebKit
+
+/// A native left-edge drawer that *pushes* the app content aside (rather than
+/// overlaying it). The sidebar panel sits at a fixed position on the left,
+/// behind the content; opening slides the whole content layer (the Capacitor
+/// web view plus any sibling overlays such as the native chat input bar) to the
+/// right to reveal it.
+///
+/// View layers in the host (`webView.superview`):
+/// • `panelHost` — the sidebar, inserted *below* the main web view. Fixed at
+/// the left, ~85% width. Hosts a transparent `contentWebView`
+/// that renders the existing `Sidebar.js`.
+/// • content — the main web view + other native overlays. Translated
+/// right by `currentOffset` when open (visual transform only,
+/// so the web layout never reflows).
+/// • `self` — a transparent gesture layer on top. Catches the left-edge
+/// open drag when closed, and tap / drag-to-close over the
+/// shifted content when open. Everything else falls through.
+class SidebarDrawer: UIView, WKScriptMessageHandler, UIGestureRecognizerDelegate {
+
+ // MARK: Callbacks to the plugin
+ var onAction: (([String: Any]) -> Void)?
+ var onStateChange: ((Bool) -> Void)?
+
+ // MARK: Tunables
+ private let panelRatio: CGFloat = 0.85
+ private let edgeStripWidth: CGFloat = 20
+ private let velocityToOpen: CGFloat = 500 // pt/s shortcut to decide snap
+
+ // MARK: Views
+ /// The sidebar, kept behind the content and revealed as content slides away.
+ private let panelHost = UIView()
+ private var contentWebView: WKWebView!
+ /// Owns the content web view so WebKit can find a presenting view controller
+ /// for native form controls (e.g. the theme `