diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9b37c77..40be252 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 1.0.6; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; @@ -489,7 +489,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 1.0.6; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_VERSION = 5.0; @@ -521,7 +521,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -554,7 +554,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService; + PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme index b134529..50e4f82 100644 --- a/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> aps-environment development + com.apple.developer.usernotifications.communication + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 8e8d560..768a855 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -35,6 +35,10 @@ Used to find forums and communities near you NSPhotoLibraryUsageDescription Access your photos to set a profile picture and share with others + NSUserActivityTypes + + INSendMessageIntent + UIBackgroundModes remote-notification diff --git a/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md b/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md new file mode 100644 index 0000000..1ad3435 --- /dev/null +++ b/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md @@ -0,0 +1,53 @@ +# Communication Notifications (avatar + app-icon badge) + +The Notification Service Extension (`NotificationService.swift`) turns incoming +pushes into **communication notifications** on iOS 15+: a large circular avatar +(the sending network's photo) with the app icon badged in the corner. + +## How it works + +1. The server (`frm.so`, `notifications.js`) sets `mutableContent: 1` and adds to + the push payload: + - `imageUrl` — a PNG of the network's logo (the server rasterizes SVG logos to + PNG via `sharp`, since iOS can't render SVG avatars). + - `senderName` — the network name (used as the conversation title; falls back + to the notification's own title). + - `isGroup` — `true`, so iOS uses the group-avatar style. +2. The extension downloads `imageUrl`, builds an `INSendMessageIntent` with the + image as the sender/group avatar, donates it, and returns + `content.updating(from: intent)`. +3. On iOS 14 the extension falls back to attaching the image as a plain thumbnail. + +## Required setup (both pieces are mandatory) + +Two things must be in place or the avatar silently won't render: + +### 1. `NSUserActivityTypes` declares the intent (already in this repo) + +`INSendMessageIntent` is declared under `NSUserActivityTypes` in **both** +`App/Info.plist` and `NotificationService/Info.plist`. Without this the donation +fails with *"Donating intent 'INSendMessageIntent' is not supported by this +extension"* and the communication styling never applies. + +### 2. Communication Notifications capability (one-time, in Xcode — cannot be scripted) + +The capability must be registered on the provisioning profile, not just present +in the entitlements file: + +1. Open `ios/App/App.xcworkspace` in Xcode. +2. Select the **App** target → **Signing & Capabilities** → **+ Capability** → + add **Communication Notifications**. + - This adds `com.apple.developer.usernotifications.communication` to + `App.entitlements` (already in this repo) and, crucially, enables the + capability on the App ID / provisioning profile. Requires a paid Apple + Developer account. +3. If the avatar still doesn't appear, add the same capability to the + **NotificationService** target as well. +4. Rebuild and reinstall on a device (communication notifications don't reliably + render on the Simulator). + +## Notes + +- Requires iOS 15.0+ for the communication style; older versions get the plain + attachment fallback automatically. +- The app's deployment target is currently 14.0, so no bump is required. diff --git a/ios/App/NotificationService/Info.plist b/ios/App/NotificationService/Info.plist index 57421eb..ad04f44 100644 --- a/ios/App/NotificationService/Info.plist +++ b/ios/App/NotificationService/Info.plist @@ -2,6 +2,10 @@ + NSUserActivityTypes + + INSendMessageIntent + NSExtension NSExtensionPointIdentifier diff --git a/ios/App/NotificationService/NotificationService.swift b/ios/App/NotificationService/NotificationService.swift index 37c2967..561405a 100644 --- a/ios/App/NotificationService/NotificationService.swift +++ b/ios/App/NotificationService/NotificationService.swift @@ -6,6 +6,7 @@ // import UserNotifications +import Intents class NotificationService: UNNotificationServiceExtension { @@ -25,21 +26,91 @@ class NotificationService: UNNotificationServiceExtension { return } - URLSession.shared.downloadTask(with: imageUrl) { location, _, error in - defer { contentHandler(content) } - guard let location = location, error == nil else { return } + let userInfo = request.content.userInfo - let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(imageUrl.lastPathComponent) + // Download the avatar (the sending network's photo), then render the + // notification. A data task lets us hand the bytes straight to INImage. + URLSession.shared.dataTask(with: imageUrl) { data, _, error in + guard let data = data, !data.isEmpty, error == nil else { + contentHandler(content) + return + } - try? FileManager.default.moveItem(at: location, to: tmpFile) - - if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) { - content.attachments = [attachment] + // iOS 15+: render as a communication notification — large circular + // avatar with the app icon badged in the corner. + if #available(iOS 15.0, *) { + self.deliverAsCommunicationNotification(content: content, imageData: data, userInfo: userInfo, contentHandler: contentHandler) + } else { + // iOS 14 fallback: attach the image as a plain thumbnail. + let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar.png") + try? data.write(to: tmpFile) + if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) { + content.attachments = [attachment] + } + contentHandler(content) } }.resume() } + @available(iOS 15.0, *) + private func deliverAsCommunicationNotification( + content: UNMutableNotificationContent, + imageData: Data, + userInfo: [AnyHashable: Any], + contentHandler: @escaping (UNNotificationContent) -> Void + ) { + // The conversation title shown in bold. Defaults to the notification's own + // title so existing copy (e.g. "Group | Alice") is preserved. + let senderName = (userInfo["senderName"] as? String) ?? content.title + let isGroup = (userInfo["isGroup"] as? Bool) ?? false + // Group related notifications into one conversation so iOS stacks them. + let conversationId = (userInfo["conversationId"] as? String) + ?? (userInfo["threadId"] as? String) + ?? content.threadIdentifier + + let avatar = INImage(imageData: imageData) + + let handle = INPersonHandle(value: conversationId, type: .unknown) + let sender = INPerson( + personHandle: handle, + nameComponents: nil, + displayName: senderName, + image: avatar, + contactIdentifier: nil, + customIdentifier: conversationId + ) + + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content.body, + speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: senderName) : nil, + conversationIdentifier: conversationId, + serviceName: nil, + sender: sender, + attachments: nil + ) + + // For a group conversation the avatar comes from the group image; for a + // 1:1 it comes from the sender's INPerson image (set above). + if isGroup { + intent.setImage(avatar, forParameterNamed: \.speakableGroupName) + } + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate(completion: nil) + + do { + let updated = try content.updating(from: intent) + contentHandler(updated) + } catch { + // If the intent can't be applied, fall back to the unmodified content + // so the notification still shows. + contentHandler(content) + } + } + override func serviceExtensionTimeWillExpire() { if let contentHandler = contentHandler, let content = bestAttemptContent { contentHandler(content) diff --git a/ios/App/Podfile b/ios/App/Podfile index 7166ed4..297f568 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -19,6 +19,7 @@ def capacitor_pods pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences' pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' + pod 'ForumCapacitorNativeInput', :path => '../../native-input' pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back' end diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index bf7b21b..a1a7e94 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -1,26 +1,28 @@ PODS: - - Capacitor (7.4.4): + - Capacitor (7.6.6): - CapacitorCordova - - CapacitorCamera (7.0.2): + - CapacitorCamera (7.0.5): - Capacitor - - CapacitorCordova (7.4.4) + - CapacitorCordova (7.6.6) - CapacitorFilesystem (7.1.8): - Capacitor - IONFilesystemLib (~> 1.1.1) - - CapacitorGeolocation (7.1.5): + - CapacitorGeolocation (7.1.8): - Capacitor - IONGeolocationLib (= 1.0.1) - - CapacitorGoogleMaps (7.2.0): + - CapacitorGoogleMaps (7.2.2): - Capacitor - Google-Maps-iOS-Utils (~> 5.0) - GoogleMaps (~> 8.4) - - CapacitorHaptics (7.0.3): + - CapacitorHaptics (7.0.5): - Capacitor - CapacitorPreferences (7.0.4): - Capacitor - CapacitorPushNotifications (7.0.6): - Capacitor - - CapacitorSplashScreen (7.0.3): + - CapacitorSplashScreen (7.0.5): + - Capacitor + - ForumCapacitorNativeInput (1.0.0): - Capacitor - Google-Maps-iOS-Utils (5.0.0): - GoogleMaps (~> 8.0) @@ -45,6 +47,7 @@ DEPENDENCIES: - "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)" - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" + - ForumCapacitorNativeInput (from `../../native-input`) - "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)" SPEC REPOS: @@ -75,26 +78,29 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/push-notifications" CapacitorSplashScreen: :path: "../../node_modules/@capacitor/splash-screen" + ForumCapacitorNativeInput: + :path: "../../native-input" NotnotsamuelCapacitorSwipeBack: :path: "../../node_modules/@notnotsamuel/capacitor-swipe-back" SPEC CHECKSUMS: - Capacitor: 09d9ff8e9618e8c4b3cab2bbee34a17215dd2fef - CapacitorCamera: 6e18d54c8ab30d7dc7b8cd93d96f9b4f57e9202a - CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced - CapacitorFilesystem: f54cd6b76be06fa7ceb219cf313d32e0d626ea87 - CapacitorGeolocation: b96474c3259dd4a294227ea8ec19140b1837cceb - CapacitorGoogleMaps: 20b5445a532f80dbb120fa99941fd094bcc88af6 - CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62 - CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153 - CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b - CapacitorSplashScreen: d06ae8804808e9f649a08e7bb7f283c77b688084 + Capacitor: 961efd309ab0e390a8081b63c561186f61a6714d + CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe + CapacitorCordova: 3a4e0027a89a59421c918d19aee9d6ba1c7a3556 + CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296 + CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a + CapacitorGoogleMaps: e3a1c0b501daa84a327b6ee807ee515a7f890403 + CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5 + CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca + CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c + CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb + ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321 GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a - NotnotsamuelCapacitorSwipeBack: 07a8985928db83b1d62a1d596246db466e69d149 + NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e -PODFILE CHECKSUM: d7fbdcfecbb81edc39c9975b338d5d15e186424b +PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/native-input/ForumCapacitorNativeInput.podspec b/native-input/ForumCapacitorNativeInput.podspec new file mode 100644 index 0000000..544b320 --- /dev/null +++ b/native-input/ForumCapacitorNativeInput.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'ForumCapacitorNativeInput' + 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-input/ios/Plugin/ChatInputBar.swift b/native-input/ios/Plugin/ChatInputBar.swift new file mode 100644 index 0000000..65ffd76 --- /dev/null +++ b/native-input/ios/Plugin/ChatInputBar.swift @@ -0,0 +1,493 @@ +import UIKit + +/// A floating chat input bar (Liquid Glass on iOS 26+, blur material fallback) +/// that pins itself above the keyboard and reports the bottom inset it occupies +/// so the web layout can pad its scroll area accordingly. +class ChatInputBar: UIView, UITextViewDelegate { + + // MARK: Callbacks to the plugin + var onTextChange: ((String) -> Void)? + var onSend: ((String) -> Void)? + var onAttach: (() -> Void)? + var onFocusChange: ((Bool) -> Void)? + /// Reports the bottom inset (in points == CSS px for this viewport) that the + /// bar + keyboard occupy at the bottom of the host view. + var onInsetChange: ((CGFloat) -> Void)? + + // MARK: Tunables + private let minTextHeight: CGFloat = 45 // ~25% taller than the old 36 + private let maxTextHeight: CGFloat = 150 + private let fieldVerticalPadding: CGFloat = 8 + private let barVerticalPadding: CGFloat = 8 + + // MARK: Views + // Hosts the controls in a scroll view with `.interactive` keyboard dismiss + // so dragging the bar down lets the keyboard follow the finger. + private let panScroll = UIScrollView() + private let contentView = UIView() + private let field = UIView() + private let fieldEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect()) + private let plusEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect()) + private let textView = UITextView() + private let placeholderLabel = UILabel() + private let plusButton = UIButton(type: .system) + private let sendButton = UIButton(type: .system) + + private weak var hostView: UIView? + private var textHeightConstraint: NSLayoutConstraint! + // Pins the *visible* bar (the field) above the keyboard. The bar view itself + // spans the whole host so its scroll view can drive interactive dismissal. + private var fieldBottomConstraint: NSLayoutConstraint! + private var canSend = true + /// True while the keyboard is up — used to widen the bar's touch-catch area + /// (see hitTest) only when there's actually a keyboard to dismiss. + private var keyboardUp = false + + /// True when the running OS gave us a real, interactive `UIGlassEffect` + /// (so the glass surfaces animate themselves on press). When false we fall + /// back to a manual press animation on the buttons. + private static var supportsInteractiveGlass = false + + // MARK: Lifecycle + + init(hostView: UIView) { + self.hostView = hostView + super.init(frame: .zero) + setupViews() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } + + private static func makeEffect() -> UIVisualEffect { + // Use real Liquid Glass on iOS 26+. Resolved dynamically via the + // Objective-C runtime so the plugin still compiles against older SDKs + // (where the UIGlassEffect symbol does not yet exist). + if #available(iOS 26.0, *), + let glassClass = NSClassFromString("UIGlassEffect") as? NSObject.Type { + let glass = glassClass.init() + // Opt into the system's built-in touch animation (the scale + tint + // "squish" on press). Set via KVC behind a responds(to:) guard so it + // compiles against SDKs that lack UIGlassEffect and never crashes if + // the property is renamed/absent — it just stays static. + let setter = Selector("setInteractive:") + if glass.responds(to: setter) { + glass.setValue(true, forKey: "interactive") + supportsInteractiveGlass = true + } + if let effect = glass as? UIVisualEffect { return effect } + } + return UIBlurEffect(style: .systemUltraThinMaterial) + } + + /// Adds the bar to the host view. The bar view fills the whole host (so its + /// scroll view's bounds overlap the keyboard and can drive interactive + /// dismissal); only the visible field/"+" capture touches (see hitTest), and + /// the visible field is pinned just above the keyboard. + func attach() { + guard let host = hostView else { return } + translatesAutoresizingMaskIntoConstraints = false + host.addSubview(self) + + NSLayoutConstraint.activate([ + leadingAnchor.constraint(equalTo: host.leadingAnchor), + trailingAnchor.constraint(equalTo: host.trailingAnchor), + topAnchor.constraint(equalTo: host.topAnchor), + bottomAnchor.constraint(equalTo: host.bottomAnchor), + ]) + + if #available(iOS 15.0, *) { + // keyboardLayoutGuide rides the keyboard and rests at the safe-area + // bottom when the keyboard is dismissed. + host.keyboardLayoutGuide.followsUndockedKeyboard = true + fieldBottomConstraint = field.bottomAnchor.constraint( + equalTo: host.keyboardLayoutGuide.topAnchor, constant: -barVerticalPadding) + } else { + fieldBottomConstraint = field.bottomAnchor.constraint( + equalTo: host.safeAreaLayoutGuide.bottomAnchor, constant: -barVerticalPadding) + } + fieldBottomConstraint.isActive = true + + observeKeyboard() + host.layoutIfNeeded() // so the field has a real frame before measuring + reportInset() + } + + func removeFromHost() { + NotificationCenter.default.removeObserver(self) + removeFromSuperview() + onInsetChange?(0) + } + + /// The bar view spans the entire host so its scroll view can drive + /// interactive keyboard dismissal, but only the visible field/"+" should + /// swallow touches — every other point falls through to the web view beneath. + /// (A touch that begins on the bar is still tracked by the scroll view's pan + /// as the finger then drags down across the rest of the screen.) + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let hit = super.hitTest(point, with: event) else { return nil } + // Always let the visible controls handle their own touches. + if hit == field || hit == plusEffect + || hit.isDescendant(of: field) || hit.isDescendant(of: plusEffect) { + return hit + } + // While the keyboard is up, also claim an invisible strip ~1.5× the input + // height above the field (and everything below it) so a downward drag + // begun just above the bar still drives the interactive dismissal. Above + // the strip, fall through to the web view. + if keyboardUp { + let fieldInSelf = field.convert(field.bounds, to: self) + let catchTop = fieldInSelf.minY - 1.5 * minTextHeight + if point.y >= catchTop { return panScroll } + } + return nil + } + + // MARK: Setup + + private func setupViews() { + // The bar itself is transparent so only the field + "+" button float. + backgroundColor = .clear + + // Scroll view that drives the native interactive keyboard dismissal. + // It fills the whole host so its bounds overlap the keyboard region: a + // downward drag that begins on the bar stays inside the scroll view as it + // travels toward the keyboard, which is what `.interactive` needs to track + // the finger. It never really scrolls (content == frame) but + // `alwaysBounceVertical` keeps its pan recognizer live. + panScroll.translatesAutoresizingMaskIntoConstraints = false + panScroll.keyboardDismissMode = .interactive + panScroll.alwaysBounceVertical = true + panScroll.showsVerticalScrollIndicator = false + panScroll.showsHorizontalScrollIndicator = false + panScroll.delaysContentTouches = false + panScroll.backgroundColor = .clear + if #available(iOS 11.0, *) { panScroll.contentInsetAdjustmentBehavior = .never } + addSubview(panScroll) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + panScroll.addSubview(contentView) + + // Circular "+" attach button — same glass + border as the input field. + // Interaction is enabled so the interactive glass effect can observe + // touches and run its built-in press animation. + plusEffect.translatesAutoresizingMaskIntoConstraints = false + plusEffect.isUserInteractionEnabled = true + plusEffect.layer.cornerRadius = minTextHeight / 2 + plusEffect.clipsToBounds = true + plusEffect.layer.borderWidth = 1 + plusEffect.layer.borderColor = UIColor.separator.cgColor + contentView.addSubview(plusEffect) + + // The button lives inside the effect's contentView so its touches are + // delivered through the glass (required for the interactive animation). + plusButton.translatesAutoresizingMaskIntoConstraints = false + plusButton.setImage(UIImage(systemName: "plus"), for: .normal) + plusButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside) + plusEffect.contentView.addSubview(plusButton) + addPressAnimation(to: plusButton, enabled: !ChatInputBar.supportsInteractiveGlass) + + // Floating rounded input field. + field.translatesAutoresizingMaskIntoConstraints = false + field.layer.cornerRadius = 18 + field.layer.borderWidth = 1 + field.layer.borderColor = UIColor.separator.cgColor + field.clipsToBounds = true + contentView.addSubview(field) + + // Glass / blur background fills the field (behind the content). + // Interaction is enabled so tapping the input runs the glass's built-in + // press animation; the text view and placeholder live in its contentView. + fieldEffect.translatesAutoresizingMaskIntoConstraints = false + fieldEffect.isUserInteractionEnabled = true + fieldEffect.layer.cornerRadius = 18 + fieldEffect.clipsToBounds = true + field.addSubview(fieldEffect) + + // Growing text view. + textView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: 17) + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets(top: 11, left: 6, bottom: 11, right: 6) + textView.delegate = self + fieldEffect.contentView.addSubview(textView) + + // Placeholder overlay. + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.font = .systemFont(ofSize: 17) + placeholderLabel.textColor = UIColor.placeholderText + placeholderLabel.isUserInteractionEnabled = false + fieldEffect.contentView.addSubview(placeholderLabel) + + // Send button — a solid (non-glass) button overlaid inside the field, + // bottom-right. Since it is not a glass surface it gets a manual press + // animation so it still scales/dims like the glass controls. + sendButton.translatesAutoresizingMaskIntoConstraints = false + sendButton.setImage(UIImage(systemName: "arrow.up"), for: .normal) + sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) + sendButton.layer.cornerRadius = 18 + sendButton.clipsToBounds = true + field.addSubview(sendButton) + addPressAnimation(to: sendButton, enabled: true) + + textHeightConstraint = textView.heightAnchor.constraint(equalToConstant: minTextHeight) + + NSLayoutConstraint.activate([ + // Scroll view fills the bar; content view matches its frame so it + // never scrolls but its pan stays active for interactive dismiss. + panScroll.topAnchor.constraint(equalTo: topAnchor), + panScroll.leadingAnchor.constraint(equalTo: leadingAnchor), + panScroll.trailingAnchor.constraint(equalTo: trailingAnchor), + panScroll.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentView.topAnchor.constraint(equalTo: panScroll.contentLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: panScroll.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: panScroll.frameLayoutGuide.widthAnchor), + contentView.heightAnchor.constraint(equalTo: panScroll.frameLayoutGuide.heightAnchor), + + // Circular "+" button sized to the input's resting height, aligned + // to the field's bottom. + plusEffect.widthAnchor.constraint(equalToConstant: minTextHeight), + plusEffect.heightAnchor.constraint(equalToConstant: minTextHeight), + plusEffect.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + plusEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor), + plusButton.leadingAnchor.constraint(equalTo: plusEffect.leadingAnchor), + plusButton.trailingAnchor.constraint(equalTo: plusEffect.trailingAnchor), + plusButton.topAnchor.constraint(equalTo: plusEffect.topAnchor), + plusButton.bottomAnchor.constraint(equalTo: plusEffect.bottomAnchor), + + field.leadingAnchor.constraint(equalTo: plusEffect.trailingAnchor, constant: 8), + field.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + // field.bottom is pinned to the keyboard guide in attach(); its top is + // driven by the text view's height. Its vertical position is therefore + // independent of the (full-host) content view. + + fieldEffect.topAnchor.constraint(equalTo: field.topAnchor), + fieldEffect.leadingAnchor.constraint(equalTo: field.leadingAnchor), + fieldEffect.trailingAnchor.constraint(equalTo: field.trailingAnchor), + fieldEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor), + + // Send button overlaid inside the field, bottom-right. + sendButton.widthAnchor.constraint(equalToConstant: 36), + sendButton.heightAnchor.constraint(equalToConstant: 36), + sendButton.trailingAnchor.constraint(equalTo: field.trailingAnchor, constant: -4), + sendButton.bottomAnchor.constraint(equalTo: field.bottomAnchor, constant: -4), + + textView.leadingAnchor.constraint(equalTo: field.leadingAnchor, constant: 6), + textView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -4), + textView.topAnchor.constraint(equalTo: field.topAnchor), + textView.bottomAnchor.constraint(equalTo: field.bottomAnchor), + textHeightConstraint, + + placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 11), + placeholderLabel.centerYAnchor.constraint(equalTo: textView.centerYAnchor), + ]) + + sendButton.backgroundColor = tintColor + updateTextHeight() + } + + // MARK: Public API + + var text: String { + get { textView.text ?? "" } + set { + textView.text = newValue + textChanged(notify: false) + } + } + + func setPlaceholder(_ value: String) { + placeholderLabel.text = value + } + + func setEnabled(_ enabled: Bool) { + canSend = enabled + textView.isEditable = enabled + sendButton.isEnabled = enabled + plusButton.isEnabled = enabled + alpha = enabled ? 1.0 : 0.6 + } + + func focus() { textView.becomeFirstResponder() } + func blur() { textView.resignFirstResponder() } + + func setVisible(_ visible: Bool) { + isHidden = !visible + if !visible { onInsetChange?(0) } else { reportInset() } + } + + // MARK: Theme + + func applyTheme(_ theme: [String: Any]) { + // The field/"+" keep a clear background so the glass material shows + // through and they read as floating; only content colors are themed. + if let txt = (theme["text"] as? String).flatMap(UIColor.fromCSS) { + textView.textColor = txt + textView.tintColor = txt + } + if let ph = (theme["placeholder"] as? String).flatMap(UIColor.fromCSS) { + placeholderLabel.textColor = ph + } + if let accent = (theme["accent"] as? String).flatMap(UIColor.fromCSS) { + plusButton.tintColor = accent + } + if let sendBg = (theme["sendBackground"] as? String).flatMap(UIColor.fromCSS) { + sendButton.backgroundColor = sendBg + } + if let sendTxt = (theme["sendText"] as? String).flatMap(UIColor.fromCSS) { + sendButton.tintColor = sendTxt + } + if let dark = theme["dark"] as? Bool { + overrideUserInterfaceStyle = dark ? .dark : .light + } + } + + // MARK: Actions + + @objc private func attachTapped() { onAttach?() } + + @objc private func sendTapped() { + guard canSend else { return } + onSend?(textView.text ?? "") + } + + // MARK: Press animation + // Manual scale + dim used for the solid send button, and for the "+" when + // the OS doesn't provide interactive glass (so it still feels pressable). + + private func addPressAnimation(to button: UIButton, enabled: Bool) { + guard enabled else { return } + button.addTarget(self, action: #selector(pressDown(_:)), + for: [.touchDown, .touchDragEnter]) + button.addTarget(self, action: #selector(pressUp(_:)), + for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit]) + } + + @objc private func pressDown(_ button: UIButton) { + UIView.animate(withDuration: 0.12, delay: 0, + options: [.allowUserInteraction, .beginFromCurrentState]) { + button.transform = CGAffineTransform(scaleX: 0.88, y: 0.88) + button.alpha = 0.7 + } + } + + @objc private func pressUp(_ button: UIButton) { + UIView.animate(withDuration: 0.28, delay: 0, + usingSpringWithDamping: 0.5, initialSpringVelocity: 0.6, + options: [.allowUserInteraction, .beginFromCurrentState]) { + button.transform = .identity + button.alpha = 1.0 + } + } + + // MARK: UITextViewDelegate + + func textViewDidChange(_ textView: UITextView) { + textChanged(notify: true) + } + + func textViewDidBeginEditing(_ textView: UITextView) { onFocusChange?(true) } + func textViewDidEndEditing(_ textView: UITextView) { onFocusChange?(false) } + + private func textChanged(notify: Bool) { + placeholderLabel.isHidden = !(textView.text ?? "").isEmpty + updateTextHeight() + if notify { onTextChange?(textView.text ?? "") } + } + + private func updateTextHeight() { + let fitting = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) + let clamped = min(max(fitting.height, minTextHeight), maxTextHeight) + if abs(textHeightConstraint.constant - clamped) > 0.5 { + textHeightConstraint.constant = clamped + textView.isScrollEnabled = fitting.height > maxTextHeight + superview?.layoutIfNeeded() + DispatchQueue.main.async { [weak self] in self?.reportInset() } + } + } + + // MARK: Inset reporting + + private func observeKeyboard() { + // Observe the *will* notifications — they fire at the START of the + // keyboard animation with the final frame, so the web layout can move in + // step with the keyboard instead of lagging until the animation ends + // (which is what the *did* notifications would do). + let names: [NSNotification.Name] = [ + UIResponder.keyboardWillChangeFrameNotification, + UIResponder.keyboardWillShowNotification, + UIResponder.keyboardWillHideNotification, + ] + for name in names { + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChange(_:)), + name: name, object: nil) + } + } + + @objc private func keyboardWillChange(_ note: Notification) { + guard let host = hostView, !isHidden else { return } + guard let end = (note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { + reportInset(); return + } + // Where the bar will rest: just above the keyboard, but never below the + // safe-area bottom (its resting place when the keyboard is dismissed). + let kbTop = host.convert(end, from: nil).minY + let safeBottom = host.bounds.height - host.safeAreaInsets.bottom + let barBottom = min(kbTop, safeBottom) + keyboardUp = barBottom < safeBottom - 0.5 + + if #available(iOS 15.0, *) { + // keyboardLayoutGuide animates the bar itself; nothing to drive here. + } else { + fieldBottomConstraint.constant = (barBottom - safeBottom) - barVerticalPadding + host.layoutIfNeeded() + } + + // Report the *target* inset (computed from the keyboard's end frame, since + // the field hasn't moved yet on a *will* notification) so the web updates + // at the animation's start. + let fieldTop = barBottom - barVerticalPadding - field.bounds.height + let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding) + onInsetChange?(inset) + } + + private func reportInset() { + guard let host = hostView, !isHidden else { return } + // The bar view spans the whole host, so measure the *visible* field's top + // (in host coordinates) and report from there down to the host bottom. + let fieldTop = field.convert(field.bounds, to: host).minY + let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding) + onInsetChange?(inset) + } +} + +// MARK: - CSS color parsing + +extension UIColor { + /// Parses the common CSS color strings that `getComputedStyle` returns + /// (`rgb(...)`, `rgba(...)`) plus `#rgb`/`#rrggbb`. + static func fromCSS(_ string: String) -> UIColor? { + let s = string.trimmingCharacters(in: .whitespaces).lowercased() + if s.hasPrefix("#") { + var hex = String(s.dropFirst()) + if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() } + guard hex.count == 6, let v = UInt32(hex, radix: 16) else { return nil } + return UIColor(red: CGFloat((v >> 16) & 0xff) / 255, + green: CGFloat((v >> 8) & 0xff) / 255, + blue: CGFloat(v & 0xff) / 255, alpha: 1) + } + if s.hasPrefix("rgb") { + let inner = s.drop { $0 != "(" }.dropFirst().prefix { $0 != ")" } + let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count >= 3, + let r = Double(parts[0]), let g = Double(parts[1]), let b = Double(parts[2]) else { return nil } + let a = parts.count >= 4 ? (Double(parts[3]) ?? 1) : 1 + return UIColor(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a)) + } + return nil + } +} diff --git a/native-input/ios/Plugin/Info.plist b/native-input/ios/Plugin/Info.plist new file mode 100644 index 0000000..1007fd9 --- /dev/null +++ b/native-input/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-input/ios/Plugin/NativeInputPlugin.h b/native-input/ios/Plugin/NativeInputPlugin.h new file mode 100644 index 0000000..5a7d549 --- /dev/null +++ b/native-input/ios/Plugin/NativeInputPlugin.h @@ -0,0 +1,7 @@ +#import + +//! Project version number for Plugin. +FOUNDATION_EXPORT double PluginVersionNumber; + +//! Project version string for Plugin. +FOUNDATION_EXPORT const unsigned char PluginVersionString[]; diff --git a/native-input/ios/Plugin/NativeInputPlugin.m b/native-input/ios/Plugin/NativeInputPlugin.m new file mode 100644 index 0000000..7dfbf51 --- /dev/null +++ b/native-input/ios/Plugin/NativeInputPlugin.m @@ -0,0 +1,17 @@ +#import +#import + +// Define the plugin using the CAP_PLUGIN Macro, and +// each method the plugin supports using the CAP_PLUGIN_METHOD macro. +CAP_PLUGIN(NativeInputPlugin, "NativeInput", + CAP_PLUGIN_METHOD(present, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setText, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(getText, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(focus, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(blur, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setPlaceholder, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setVisible, CAPPluginReturnPromise); +) diff --git a/native-input/ios/Plugin/NativeInputPlugin.swift b/native-input/ios/Plugin/NativeInputPlugin.swift new file mode 100644 index 0000000..2dea100 --- /dev/null +++ b/native-input/ios/Plugin/NativeInputPlugin.swift @@ -0,0 +1,117 @@ +import Foundation +import Capacitor + +@objc(NativeInputPlugin) +public class NativeInputPlugin: CAPPlugin { + + private var bar: ChatInputBar? + private var currentChatId: String? + + // MARK: Helpers + + private func hostView() -> UIView? { + // Pin the bar as a sibling of the web view inside the same parent so it + // can float above the web content and track the keyboard. + return webView?.superview ?? webView + } + + private func ensureBar() -> ChatInputBar? { + if let bar = bar { return bar } + guard let host = hostView() else { return nil } + let bar = ChatInputBar(hostView: host) + bar.onTextChange = { [weak self] text in + self?.notifyListeners("inputChange", data: ["text": text]) + } + bar.onSend = { [weak self] text in + self?.notifyListeners("send", data: ["text": text]) + } + bar.onAttach = { [weak self] in + self?.notifyListeners("attach", data: [:]) + } + bar.onFocusChange = { [weak self] focused in + self?.notifyListeners("focusChange", data: ["focused": focused]) + } + bar.onInsetChange = { [weak self] inset in + self?.notifyListeners("heightChange", data: ["bottomInset": Double(inset)]) + } + bar.attach() + self.bar = bar + return bar + } + + // MARK: Plugin methods + + @objc func present(_ call: CAPPluginCall) { + let chatId = call.getString("chatId") ?? "" + let placeholder = call.getString("placeholder") ?? "" + let text = call.getString("text") ?? "" + let canSend = call.getBool("canSend") ?? true + let theme = call.getObject("theme") + + DispatchQueue.main.async { + guard let bar = self.ensureBar() else { call.reject("No host view"); return } + bar.setVisible(true) + if let theme = theme { bar.applyTheme(theme) } + bar.setPlaceholder(placeholder) + bar.setEnabled(canSend) + // Only reset text when switching to a different chat; otherwise keep + // whatever the user already had typed in this bar. + if self.currentChatId != chatId { + bar.text = text + self.currentChatId = chatId + } + call.resolve() + } + } + + @objc func dismiss(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.bar?.removeFromHost() + self.bar = nil + self.currentChatId = nil + call.resolve() + } + } + + @objc func setText(_ call: CAPPluginCall) { + let text = call.getString("text") ?? "" + DispatchQueue.main.async { + self.bar?.text = text + call.resolve() + } + } + + @objc func getText(_ call: CAPPluginCall) { + DispatchQueue.main.async { + call.resolve(["text": self.bar?.text ?? ""]) + } + } + + @objc func focus(_ call: CAPPluginCall) { + DispatchQueue.main.async { self.bar?.focus(); call.resolve() } + } + + @objc func blur(_ call: CAPPluginCall) { + DispatchQueue.main.async { self.bar?.blur(); call.resolve() } + } + + @objc func setPlaceholder(_ call: CAPPluginCall) { + let placeholder = call.getString("placeholder") ?? "" + DispatchQueue.main.async { self.bar?.setPlaceholder(placeholder); call.resolve() } + } + + @objc func setEnabled(_ call: CAPPluginCall) { + let enabled = call.getBool("enabled") ?? true + DispatchQueue.main.async { self.bar?.setEnabled(enabled); call.resolve() } + } + + @objc func setTheme(_ call: CAPPluginCall) { + let theme = call.getObject("theme") ?? [:] + DispatchQueue.main.async { self.bar?.applyTheme(theme); call.resolve() } + } + + @objc func setVisible(_ call: CAPPluginCall) { + let visible = call.getBool("visible") ?? true + DispatchQueue.main.async { self.bar?.setVisible(visible); call.resolve() } + } +} diff --git a/native-input/package.json b/native-input/package.json new file mode 100644 index 0000000..abc5068 --- /dev/null +++ b/native-input/package.json @@ -0,0 +1,31 @@ +{ + "name": "@forum/capacitor-native-input", + "version": "1.0.0", + "description": "Native iOS chat input bar (Liquid Glass) for the Forum app.", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "files": [ + "dist/", + "ios/Plugin/", + "ForumCapacitorNativeInput.podspec" + ], + "author": "Forum", + "license": "MIT", + "keywords": [ + "capacitor", + "plugin", + "native", + "ios", + "keyboard", + "input" + ], + "peerDependencies": { + "@capacitor/core": "^7.0.0" + }, + "capacitor": { + "ios": { + "src": "ios" + } + } +} diff --git a/package.json b/package.json index 54a4cd3..b4694be 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,11 @@ "@capacitor/geolocation": "^7.1.5", "@capacitor/google-maps": "^7.2.0", "@capacitor/haptics": "^7.0.3", - "@capacitor/ios": "^7.4.4", + "@capacitor/ios": "^7.6.6", "@capacitor/preferences": "^7.0.4", "@capacitor/push-notifications": "^7.0.6", "@capacitor/splash-screen": "^7.0.3", + "@forum/capacitor-native-input": "file:native-input", "@notnotsamuel/capacitor-swipe-back": "^3.0.2" }, "devDependencies": { diff --git a/readme.md b/readme.md index 98429d2..03b5164 100644 --- a/readme.md +++ b/readme.md @@ -21,8 +21,6 @@ If getting black screen: npx cap sync iOS -## iOS: - ### Browser: Dev Front and Dev Back (localhost) This option should be at the top level of capacitor.config.json "server": { @@ -38,9 +36,6 @@ npx serve dist If you need to login again: run localStorage.clear() in the browser dev tools console and then refresh the page. - - - ## iOS: ### Dev Front, Dev Back (localhost) diff --git a/src/mobileutil.js b/src/mobileutil.js index 28028ec..068e440 100644 --- a/src/mobileutil.js +++ b/src/mobileutil.js @@ -6,6 +6,7 @@ import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { Geolocation } from '@capacitor/geolocation'; import { SplashScreen } from '@capacitor/splash-screen'; import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back'; +import { NativeInput } from '@forum/capacitor-native-input'; window.capacitor = { Preferences, @@ -19,7 +20,8 @@ window.capacitor = { CameraSource, Geolocation, SplashScreen, - CapacitorSwipeBackPlugin + CapacitorSwipeBackPlugin, + NativeInput } window.mobileUtil = class mobileUtil {