mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## Summary Refresh mobile conversations with left-aligned grouped bubbles without tails, tighter consecutive-message spacing, distinct sender colors, theme-aware incoming backgrounds, and compact overlapping reaction trays. Attach reply counts beside reactions with up to three participant avatars; keep outgoing mentions and links readable in both themes. Coordinate a 500 ms composer-to-message transition with spring contraction and an anchored avatar. Message ownership follows the signing identity so profile reloads cannot recolor outgoing bubbles. Center channel activities without timestamps or icons, keep names tappable, and show two affected members plus a **See all** sheet. Hide legacy purpose activities, match media corners to bubble groups, and add iOS status-bar scroll-to-top. This is an independent mobile PR; no desktop changes or color picker. ## Validation - Full `just mobile-check` passed; `just mobile-test` passed 3,275 tests with 4 skips, plus 3 unconfigured-gateway checks. - Fixed message-widget lint warnings and a stale notification fixture that blocked analysis. Made the video-controller disposal test wait for lifecycle events instead of fixed delays. Mobile pre-push checks are enabled. - Physical iPhone previews were installed throughout development. The latest bubble, reply-avatar, contrast, and spacing updates were installed and launched on the physical iPhone. Final motion and status-bar behavior still need device visual verification; Android has automated coverage but no physical-device verification. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
136 lines
5.7 KiB
Swift
136 lines
5.7 KiB
Swift
import Flutter
|
|
import UIKit
|
|
|
|
/// Owns one UIKit message surface and completes its Flutter request on dismissal.
|
|
final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
|
private let channel: FlutterMethodChannel
|
|
private weak var parent: UIViewController?
|
|
private weak var presented: UIViewController?
|
|
private var pendingResult: FlutterResult?
|
|
private var requestID: String?
|
|
private var terminalValue: [String: String]?
|
|
|
|
init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
|
|
channel = FlutterMethodChannel(name: "buzz/native_message_presentation", binaryMessenger: messenger)
|
|
parent = parentViewController
|
|
super.init()
|
|
channel.setMethodCallHandler { [weak self] call, result in
|
|
guard let self else { result(nil); return }
|
|
self.handle(call, result: result)
|
|
}
|
|
}
|
|
|
|
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
guard let data = call.arguments as? [String: Any] else { result(nil); return }
|
|
if call.method == "supportsMessage" {
|
|
result(["supported": true])
|
|
return
|
|
}
|
|
if call.method == "updateReactions" {
|
|
if data["requestId"] as? String == requestID,
|
|
let sheet = presented as? NativeReactionDetailsViewController,
|
|
let reactions = data["reactions"] as? [[String: Any]] {
|
|
sheet.updateReactions(reactions)
|
|
}
|
|
result([:])
|
|
return
|
|
}
|
|
if call.method == "updateProfiles" {
|
|
if data["requestId"] as? String == requestID,
|
|
let sheet = presented as? NativeReactionDetailsViewController,
|
|
let profiles = data["profiles"] as? [String: [String: Any]] {
|
|
sheet.updateProfiles(profiles)
|
|
}
|
|
result([:])
|
|
return
|
|
}
|
|
guard call.method == "message" || call.method == "reactions" else {
|
|
result(FlutterMethodNotImplemented)
|
|
return
|
|
}
|
|
// Ignore a second long press while presentation/dismissal is in flight.
|
|
guard pendingResult == nil else { result(["busy": true]); return }
|
|
let root = parent ?? UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.filter { $0.activationState == .foregroundActive }
|
|
.flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController
|
|
guard let parent = root, parent.view.window != nil else { result(nil); return }
|
|
guard parent.presentedViewController == nil else { result(["busy": true]); return }
|
|
let controller: UIViewController
|
|
if call.method == "message" {
|
|
guard let rect = NativeMessageMenuViewController.sourceRect(data),
|
|
let bytes = data["previewBytes"] as? FlutterStandardTypedData,
|
|
let image = UIImage(data: bytes.data) else { result(nil); return }
|
|
// Capture the Flutter message boundary, never the screen behind it.
|
|
// Its transparent pixels and exclusion of attached reactions are preserved.
|
|
let snapshot = UIImageView(image: image)
|
|
snapshot.contentMode = .scaleAspectFit
|
|
let menu = NativeMessageMenuViewController(data: data, sourceRect: rect, preview: snapshot)
|
|
menu.onPreviewReady = { [weak self] ready in
|
|
self?.channel.invokeMethod("messagePresented", arguments: data["requestId"]) { _ in ready() }
|
|
}
|
|
menu.onSelect = { [weak self] value in self?.dismiss(value) }
|
|
menu.modalPresentationStyle = .overFullScreen
|
|
controller = menu
|
|
} else {
|
|
let sheet = NativeReactionDetailsViewController(data: data)
|
|
sheet.onClose = { [weak self] in self?.dismiss([:]) }
|
|
sheet.onAddReaction = { [weak self] in
|
|
self?.dismiss(["action": "addReaction"])
|
|
}
|
|
sheet.onToggle = { [weak self] emoji in
|
|
self?.channel.invokeMethod("toggleReaction", arguments: [
|
|
"requestId": data["requestId"] as? String ?? "", "emoji": emoji,
|
|
])
|
|
}
|
|
sheet.modalPresentationStyle = .pageSheet
|
|
if let presentation = sheet.sheetPresentationController {
|
|
presentation.overrideTraitCollection = UITraitCollection(userInterfaceStyle: sheet.overrideUserInterfaceStyle)
|
|
presentation.detents = [.medium(), .large()]
|
|
presentation.prefersGrabberVisible = true
|
|
presentation.prefersScrollingExpandsWhenScrolledToEdge = true
|
|
}
|
|
controller = sheet
|
|
}
|
|
controller.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
|
|
pendingResult = result
|
|
requestID = data["requestId"] as? String
|
|
presented = controller
|
|
controller.presentationController?.delegate = self
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
parent.present(controller, animated: call.method == "reactions" && !UIAccessibility.isReduceMotionEnabled)
|
|
}
|
|
|
|
private func dismiss(_ value: [String: String]) {
|
|
guard pendingResult != nil, terminalValue == nil else { return }
|
|
terminalValue = value
|
|
guard let presented else { complete(value); return }
|
|
let completion: () -> Void = { [weak self] in
|
|
guard let self, self.presented === presented else { return }
|
|
self.complete(value)
|
|
}
|
|
if let menu = presented as? NativeMessageMenuViewController {
|
|
menu.animateOut {
|
|
menu.dismiss(animated: false, completion: completion)
|
|
}
|
|
} else {
|
|
presented.dismiss(animated: !UIAccessibility.isReduceMotionEnabled, completion: completion)
|
|
}
|
|
}
|
|
|
|
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
guard presented === presentationController.presentedViewController else { return }
|
|
complete([:])
|
|
}
|
|
|
|
private func complete(_ value: [String: String]) {
|
|
let result = pendingResult
|
|
let terminal = terminalValue ?? value
|
|
terminalValue = nil
|
|
pendingResult = nil
|
|
presented = nil
|
|
requestID = nil
|
|
result?(terminal)
|
|
}
|
|
}
|