mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
## Summary Replace message long-press popovers with the shared grouped bottom sheet: an emoji row, Reply / Copy link / Remind me tiles, and remaining actions in regular sections. Match emoji backgrounds to the sections and use the theme primary color for the selected reaction. Keep the scrolling date above the navigation blur in channels and threads. Restore composer focus when the sheet is canceled, keep section gaps consistent, and soften shared dividers to 7% opacity. The reaction details sheet shows all reactors by default. Emoji count chips toggle your own reaction, show your selected state, and update live without dismissing the sheet. Unselected reactions have visible outlines; adding a supported emoji plays the chat-style burst and respects reduced motion. ### Related issue No matching issue found. Related sheet styling: #4911. ### Testing - Full mobile checks and tests passed, including 23 reaction tests and composer-focus regression coverage. - Analysis and file-size checks passed; signed iOS build installed and launched on a physical iPhone. - Full `just ci` stopped during Rust compilation due to low disk space. - On-device visual capture unavailable. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
124 lines
5.2 KiB
Swift
124 lines
5.2 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?
|
|
|
|
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.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 let presented else { complete(value); return }
|
|
let completion: () -> Void = { [weak self] in 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) {
|
|
complete([:])
|
|
}
|
|
|
|
private func complete(_ value: [String: String]) {
|
|
let result = pendingResult
|
|
pendingResult = nil
|
|
presented = nil
|
|
requestID = nil
|
|
result?(value)
|
|
}
|
|
}
|