From f7b5b0ce77f6d10b0840b9dc5c4479afc00e4eda Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 11:24:26 +0100 Subject: [PATCH 01/12] Refine mobile emoji picker Signed-off-by: kenny lopez --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 9 + mobile/ios/Runner/NativeEmojiPicker.swift | 864 ++++++++++++++++++ .../lib/features/channels/emoji_picker.dart | 192 +++- .../channels/emoji_picker/category_rail.dart | 97 ++ .../channels/emoji_picker/emoji_grid.dart | 7 +- .../emoji_picker/ios_native_picker.dart | 76 ++ .../channels/emoji_picker/search_field.dart | 124 +-- .../features/channels/emoji_picker_test.dart | 335 ++++++- 9 files changed, 1580 insertions(+), 128 deletions(-) create mode 100644 mobile/ios/Runner/NativeEmojiPicker.swift create mode 100644 mobile/lib/features/channels/emoji_picker/ios_native_picker.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 45cf085cb79..37978deaa0e 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; }; 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; }; 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; }; + 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -63,6 +64,7 @@ 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; + 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -188,6 +190,7 @@ 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */, 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */, + 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -425,6 +428,7 @@ 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */, 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */, 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */, + 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index fa68e539dd0..ede3c530ffe 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -10,6 +10,7 @@ import UserNotifications private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? + private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? override func application( _ application: UIApplication, @@ -113,6 +114,14 @@ import UserNotifications messenger: messenger, parentViewController: nativeAttachmentRegistrar?.viewController ) + + let nativeEmojiPickerRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeEmojiPicker" + ) + nativeEmojiPickerCoordinator = NativeEmojiPickerCoordinator( + messenger: messenger, + parentViewController: nativeEmojiPickerRegistrar?.viewController + ) } private static func handleQrScannerMethodCall( diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift new file mode 100644 index 00000000000..c5783030b48 --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -0,0 +1,864 @@ +import Flutter +import SwiftUI +import UIKit + +private struct NativeEmojiPickerAppearance { + let surface: UIColor + let control: UIColor + let text: UIColor + let secondaryText: UIColor + let accent: UIColor + let divider: UIColor + let isDark: Bool + + init(arguments: [String: Any]) { + surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground) + control = Self.color( + arguments["controlColor"], + fallback: .secondarySystemBackground + ) + text = Self.color(arguments["textColor"], fallback: .label) + secondaryText = Self.color( + arguments["secondaryTextColor"], + fallback: .secondaryLabel + ) + accent = Self.color(arguments["accentColor"], fallback: .systemBlue) + divider = Self.color(arguments["dividerColor"], fallback: .separator) + isDark = arguments["isDark"] as? Bool ?? false + } + + private static func color(_ raw: Any?, fallback: UIColor) -> UIColor { + guard let value = (raw as? NSNumber)?.uint32Value else { return fallback } + let alpha = CGFloat((value >> 24) & 0xFF) / 255 + let red = CGFloat((value >> 16) & 0xFF) / 255 + let green = CGFloat((value >> 8) & 0xFF) / 255 + let blue = CGFloat(value & 0xFF) / 255 + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +private struct NativeEmojiItem: Identifiable, Hashable { + let id: String + let shortcode: String + let value: String + let name: String + let keywords: [String] + let glyph: String? + let skinVariants: [String] + let imageURL: URL? +} + +private struct NativeEmojiSkinTone: Identifiable { + let id: Int + let label: String + let color: UIColor +} + +private let nativeEmojiSkinTones = [ + NativeEmojiSkinTone( + id: 0, + label: "Default", + color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1) + ), + NativeEmojiSkinTone( + id: 1, + label: "Light", + color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1) + ), + NativeEmojiSkinTone( + id: 2, + label: "Medium-light", + color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1) + ), + NativeEmojiSkinTone( + id: 3, + label: "Medium", + color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1) + ), + NativeEmojiSkinTone( + id: 4, + label: "Medium-dark", + color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1) + ), + NativeEmojiSkinTone( + id: 5, + label: "Dark", + color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1) + ), +] + +private func validNativeEmojiSkinTone(_ value: Int) -> Int { + nativeEmojiSkinTones.indices.contains(value) ? value : 0 +} + +private struct NativeEmojiSection: Identifiable { + let id: String + let title: String + let systemImage: String + let items: [NativeEmojiItem] +} + +private struct NativeEmojiPickerData { + let sections: [NativeEmojiSection] + let standardItems: [NativeEmojiItem] + let customItems: [NativeEmojiItem] +} + +private enum NativeEmojiPickerDataLoader { + static let assetPath = "assets/emoji/emoji-data.json" + + static func load(arguments: [String: Any]) -> NativeEmojiPickerData? { + let key = FlutterDartProject.lookupKey(forAsset: assetPath) + let url = Bundle.main.bundleURL.appendingPathComponent(key) + guard let data = try? Data(contentsOf: url) else { return nil } + return parse(data: data, arguments: arguments) + } + + static func parse( + data: Data, + arguments: [String: Any] + ) -> NativeEmojiPickerData? { + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawCategories = root["categories"] as? [[String: Any]], + let rawEmoji = root["emoji"] as? [String: Any] + else { + return nil + } + + var sections: [NativeEmojiSection] = [] + var standardItems: [NativeEmojiItem] = [] + var byValue: [String: NativeEmojiItem] = [:] + + for category in rawCategories { + guard + let categoryID = category["id"] as? String, + let emojiIDs = category["emoji"] as? [String] + else { + continue + } + + var items: [NativeEmojiItem] = [] + for emojiID in emojiIDs { + guard let record = rawEmoji[emojiID] as? [String: Any] else { continue } + let name = record["n"] as? String ?? emojiID + let keywords = record["k"] as? [String] ?? [] + let glyphs: [String] + if let values = record["u"] as? [String] { + glyphs = values + } else if let value = record["u"] as? String { + glyphs = [value] + } else { + glyphs = [] + } + + guard let defaultGlyph = glyphs.first else { continue } + let item = NativeEmojiItem( + id: emojiID, + shortcode: emojiID, + value: defaultGlyph, + name: name, + keywords: keywords, + glyph: defaultGlyph, + skinVariants: glyphs, + imageURL: nil + ) + items.append(item) + standardItems.append(item) + for glyph in glyphs where byValue[glyph] == nil { + byValue[glyph] = item + } + } + + sections.append( + NativeEmojiSection( + id: categoryID, + title: categoryTitle(categoryID), + systemImage: categorySymbol(categoryID), + items: items + ) + ) + } + + let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? [] + let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in + guard + let shortcode = raw["shortcode"] as? String, + let urlString = raw["url"] as? String, + let url = URL(string: urlString) + else { + return nil + } + return NativeEmojiItem( + id: "custom-\(shortcode)", + shortcode: shortcode, + value: ":\(shortcode):", + name: shortcode, + keywords: [], + glyph: nil, + skinVariants: [], + imageURL: url + ) + } + let customByValue = Dictionary( + customItems.map { ($0.value, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let recentValues = arguments["recent"] as? [String] ?? [] + var seenRecentIDs: Set = [] + let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in + guard let item = byValue[value] ?? customByValue[value] else { return nil } + return seenRecentIDs.insert(item.id).inserted ? item : nil + } + if !recentItems.isEmpty { + sections.insert( + NativeEmojiSection( + id: "frequent", + title: "Frequently used", + systemImage: "clock", + items: recentItems + ), + at: 0 + ) + } + + if !customItems.isEmpty { + sections.append( + NativeEmojiSection( + id: "custom", + title: "Custom", + systemImage: "sparkles", + items: customItems + ) + ) + } + + return NativeEmojiPickerData( + sections: sections, + standardItems: standardItems, + customItems: customItems + ) + } + + private static func categoryTitle(_ id: String) -> String { + switch id { + case "people": return "Smileys & People" + case "nature": return "Animals & Nature" + case "foods": return "Food & Drink" + case "activity": return "Activity" + case "places": return "Travel & Places" + case "objects": return "Objects" + case "symbols": return "Symbols" + case "flags": return "Flags" + default: return id.capitalized + } + } + + private static func categorySymbol(_ id: String) -> String { + switch id { + case "people": return "face.smiling" + case "nature": return "leaf" + case "foods": return "fork.knife" + case "activity": return "figure.run" + case "places": return "airplane" + case "objects": return "lightbulb" + case "symbols": return "heart" + case "flags": return "flag" + default: return "circle.grid.3x3" + } + } +} + +private struct NativeEmojiSearchScore: Comparable { + let tier: Int + let detail: Int + let length: Int + let code: String + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.tier != rhs.tier { return lhs.tier < rhs.tier } + if lhs.detail != rhs.detail { return lhs.detail < rhs.detail } + if lhs.length != rhs.length { return lhs.length < rhs.length } + return lhs.code < rhs.code + } +} + +private enum NativeEmojiSearch { + static func results( + query: String, + items: [NativeEmojiItem] + ) -> [NativeEmojiItem] { + items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in + guard let score = score(query: query, item: item) else { return nil } + return (item, score) + } + .sorted { $0.1 < $1.1 } + .map(\.0) + } + + private static func score( + query: String, + item: NativeEmojiItem + ) -> NativeEmojiSearchScore? { + let normalizedQuery = collapse(query) + guard !normalizedQuery.isEmpty else { return nil } + let code = item.shortcode.lowercased() + let normalizedCode = collapse(code) + + if normalizedCode == normalizedQuery { + return makeScore(tier: 0, detail: 0, code: code) + } + if normalizedCode.hasPrefix(normalizedQuery) { + return makeScore(tier: 1, detail: 0, code: code) + } + + let words = ([item.name] + item.keywords) + .flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) } + .map(String.init) + if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) { + return makeScore(tier: 2, detail: index, code: code) + } + if let range = normalizedCode.range(of: normalizedQuery) { + return makeScore( + tier: 3, + detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound), + code: code + ) + } + if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) { + return makeScore(tier: 4, detail: index, code: code) + } + if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) { + return makeScore(tier: 5, detail: span, code: code) + } + return nil + } + + private static func makeScore( + tier: Int, + detail: Int, + code: String + ) -> NativeEmojiSearchScore { + NativeEmojiSearchScore( + tier: tier, + detail: detail, + length: code.count, + code: code + ) + } + + private static func collapse(_ value: String) -> String { + value.lowercased().filter { !":_ -\t\n".contains($0) } + } + + private static func subsequenceSpan(_ query: String, in target: String) -> Int? { + let queryCharacters = Array(query) + guard !queryCharacters.isEmpty else { return nil } + var queryIndex = 0 + var first: Int? + var last = 0 + for (targetIndex, character) in target.enumerated() { + guard character == queryCharacters[queryIndex] else { continue } + if first == nil { first = targetIndex } + last = targetIndex + queryIndex += 1 + if queryIndex == queryCharacters.count { + return last - (first ?? last) + } + } + return nil + } +} + +private struct NativeEmojiPickerView: View { + let data: NativeEmojiPickerData + let appearance: NativeEmojiPickerAppearance + let onSelect: (String) -> Void + let onSkinToneChanged: (Int) -> Void + let onClose: () -> Void + + @State private var query = "" + @State private var selectedSectionID: String? + @State private var selectedSkinTone: Int + + private let columns = Array( + repeating: GridItem(.flexible(minimum: 36), spacing: 0), + count: 8 + ) + + init( + data: NativeEmojiPickerData, + appearance: NativeEmojiPickerAppearance, + initialSkinTone: Int, + onSelect: @escaping (String) -> Void, + onSkinToneChanged: @escaping (Int) -> Void, + onClose: @escaping () -> Void + ) { + self.data = data + self.appearance = appearance + self.onSelect = onSelect + self.onSkinToneChanged = onSkinToneChanged + self.onClose = onClose + _selectedSkinTone = State( + initialValue: validNativeEmojiSkinTone(initialSkinTone) + ) + } + + var body: some View { + ScrollViewReader { proxy in + VStack(spacing: 0) { + header + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + categoryRail(proxy) + } + Divider().overlay(Color(uiColor: appearance.divider)) + pickerContent + } + .background(Color(uiColor: appearance.surface)) + .onAppear { + selectedSectionID = data.sections.first?.id + } + } + } + + private var header: some View { + HStack(spacing: 8) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + TextField("Search emoji", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.search) + .foregroundStyle(Color(uiColor: appearance.text)) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(Color(uiColor: appearance.control), in: Capsule()) + .overlay { + Capsule() + .stroke(Color(uiColor: appearance.divider), lineWidth: 1) + } + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(uiColor: appearance.text)) + .frame(width: 44, height: 44) + .background(Color(uiColor: appearance.control), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close sheet") + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } + + private func categoryRail(_ proxy: ScrollViewProxy) -> some View { + HStack(spacing: 0) { + ForEach(data.sections) { section in + Button { + selectedSectionID = section.id + withAnimation(.easeOut(duration: 0.24)) { + proxy.scrollTo("section-\(section.id)", anchor: .top) + } + } label: { + Image(systemName: section.systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle( + Color( + uiColor: selectedSectionID == section.id + ? appearance.accent : appearance.secondaryText + ) + ) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background( + selectedSectionID == section.id + ? Color(uiColor: appearance.control) : Color.clear, + in: Circle() + ) + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .accessibilityLabel(section.title) + } + Divider() + .frame(height: 24) + .overlay(Color(uiColor: appearance.divider)) + skinToneSelector + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 16) + .frame(height: 44) + } + + private var skinToneSelector: some View { + Menu { + ForEach(nativeEmojiSkinTones) { tone in + Button { + selectedSkinTone = tone.id + onSkinToneChanged(tone.id) + } label: { + Label { + Text(tone.label) + } icon: { + Image(uiImage: skinTonePreviewImage(tone)) + .renderingMode(.original) + } + } + } + } label: { + skinToneDot(nativeEmojiSkinTones[selectedSkinTone]) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.plain) + .accessibilityLabel("Skin tone") + } + + private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View { + Circle() + .fill(Color(uiColor: tone.color)) + .frame(width: 16, height: 16) + .overlay { + Circle() + .fill( + LinearGradient( + colors: [.white.opacity(0.2), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .blendMode(.overlay) + } + .overlay { + Circle().stroke(.black.opacity(0.8), lineWidth: 1) + } + } + + private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage { + let size = CGSize(width: 16, height: 16) + return UIGraphicsImageRenderer(size: size).image { rendererContext in + let context = rendererContext.cgContext + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5) + let circle = UIBezierPath(ovalIn: rect) + + tone.color.setFill() + circle.fill() + + if let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [ + UIColor.white.withAlphaComponent(0.2).cgColor, + UIColor.clear.cgColor, + ] as CFArray, + locations: [0, 1] + ) { + context.saveGState() + circle.addClip() + context.setBlendMode(.overlay) + context.drawLinearGradient( + gradient, + start: CGPoint(x: size.width / 2, y: 0), + end: CGPoint(x: size.width / 2, y: size.height), + options: [] + ) + context.restoreGState() + } + + UIColor.black.withAlphaComponent(0.8).setStroke() + circle.lineWidth = 1 + circle.stroke() + } + } + + @ViewBuilder + private var pickerContent: some View { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.isEmpty { + sectionList(data.sections) + } else { + let custom = NativeEmojiSearch.results( + query: trimmedQuery, + items: data.customItems + ) + let standard = NativeEmojiSearch.results( + query: trimmedQuery, + items: data.standardItems + ) + let sections = [ + NativeEmojiSection( + id: "search-custom", + title: "Custom", + systemImage: "sparkles", + items: custom + ), + NativeEmojiSection( + id: "search-standard", + title: "Emoji", + systemImage: "face.smiling", + items: standard + ), + ].filter { !$0.items.isEmpty } + + if sections.isEmpty { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 28)) + Text("No emoji found").font(.body) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + } else { + sectionList(sections) + } + } + } + + private func sectionList(_ sections: [NativeEmojiSection]) -> some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(sections) { section in + Section { + LazyVGrid(columns: columns, spacing: 0) { + ForEach(section.items) { item in + emojiButton(item) + } + } + .padding(.horizontal, 16) + } header: { + HStack { + Text(section.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 30) + .background(Color(uiColor: appearance.surface)) + .id("section-\(section.id)") + } + } + } + .padding(.bottom, 8) + } + .scrollDismissesKeyboard(.interactively) + } + + private func emojiButton(_ item: NativeEmojiItem) -> some View { + let value = displayValue(for: item) + return Button { + onSelect(value) + } label: { + Group { + if let url = item.imageURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit() + case .failure: + Image(systemName: "sparkles") + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + default: + ProgressView().controlSize(.mini) + } + } + .frame(width: 28, height: 28) + } else { + Text(value).font(.system(size: 28)) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.name) + } + + private func displayValue(for item: NativeEmojiItem) -> String { + guard item.imageURL == nil else { return item.value } + guard item.skinVariants.indices.contains(selectedSkinTone) else { + return item.skinVariants.first ?? item.value + } + return item.skinVariants[selectedSkinTone] + } +} + +final class NativeEmojiPickerCoordinator: NSObject, + UIAdaptivePresentationControllerDelegate +{ + private let channel: FlutterMethodChannel + private weak var parentViewController: UIViewController? + private weak var presentedController: UIViewController? + private var didNotifyDismissal = false + + init( + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + channel = FlutterMethodChannel( + name: "buzz/native_emoji_picker", + binaryMessenger: messenger + ) + self.parentViewController = parentViewController + super.init() + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + private func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard call.method == "present" else { + result(FlutterMethodNotImplemented) + return + } + guard let arguments = call.arguments as? [String: Any] else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected emoji-picker configuration.", + details: nil + ) + ) + return + } + + DispatchQueue.main.async { [weak self] in + result(self?.present(arguments: arguments) ?? false) + } + } + + @MainActor + private func present(arguments: [String: Any]) -> Bool { + guard presentedController == nil else { return true } + guard + let data = NativeEmojiPickerDataLoader.load(arguments: arguments), + let presenter = topViewController( + from: parentViewController ?? activeWindowRootViewController() + ) + else { + return false + } + + didNotifyDismissal = false + let appearance = NativeEmojiPickerAppearance(arguments: arguments) + let content = NativeEmojiPickerView( + data: data, + appearance: appearance, + initialSkinTone: (arguments["skinTone"] as? NSNumber)?.intValue ?? 0, + onSelect: { [weak self] emoji in self?.select(emoji) }, + onSkinToneChanged: { [weak self] value in + self?.channel.invokeMethod("skinToneChanged", arguments: value) + }, + onClose: { [weak self] in self?.dismiss() } + ) + let controller = UIHostingController(rootView: content) + controller.view.backgroundColor = appearance.surface + controller.modalPresentationStyle = .pageSheet + controller.overrideUserInterfaceStyle = appearance.isDark ? .dark : .light + + if let sheet = controller.sheetPresentationController { + let compactID = UISheetPresentationController.Detent.Identifier( + "buzz.emoji.compact" + ) + let mediumID = UISheetPresentationController.Detent.Identifier( + "buzz.emoji.medium" + ) + sheet.detents = [ + .custom(identifier: compactID) { context in + context.maximumDetentValue * 0.34 + }, + .custom(identifier: mediumID) { context in + context.maximumDetentValue * 0.67 + }, + .large(), + ] + sheet.selectedDetentIdentifier = mediumID + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.prefersEdgeAttachedInCompactHeight = false + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true + } + + presentedController = controller + presenter.present(controller, animated: true) { [weak self, weak controller] in + controller?.presentationController?.delegate = self + } + return true + } + + @MainActor + private func select(_ emoji: String) { + channel.invokeMethod("selected", arguments: emoji) + dismiss() + } + + @MainActor + private func dismiss() { + guard let controller = presentedController else { + notifyDismissalIfNeeded() + return + } + controller.dismiss(animated: true) { [weak self] in + self?.notifyDismissalIfNeeded() + } + } + + func presentationControllerDidDismiss( + _ presentationController: UIPresentationController + ) { + notifyDismissalIfNeeded() + } + + @MainActor + private func notifyDismissalIfNeeded() { + guard !didNotifyDismissal else { return } + didNotifyDismissal = true + presentedController = nil + channel.invokeMethod("dismissed", arguments: nil) + } + + @MainActor + private func activeWindowRootViewController() -> UIViewController? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + } + + @MainActor + private func topViewController( + from viewController: UIViewController? + ) -> UIViewController? { + if let presented = viewController?.presentedViewController { + return topViewController(from: presented) + } + if let navigation = viewController as? UINavigationController { + return topViewController(from: navigation.visibleViewController) + } + if let tab = viewController as? UITabBarController { + return topViewController(from: tab.selectedViewController) + } + return viewController + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index e8e62199d70..264624b00a1 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -11,16 +15,17 @@ import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_search.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_sheet_header.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'recent_emoji_provider.dart'; part 'emoji_picker/search_field.dart'; part 'emoji_picker/category_rail.dart'; part 'emoji_picker/emoji_grid.dart'; +part 'emoji_picker/ios_native_picker.dart'; -/// Height of the picker sheet as a fraction of the screen. The full emoji set -/// is ~1.9k glyphs; the old fixed 340px sheet only ever showed a hand-picked -/// subset and had no room to browse. +/// Android keeps the established Flutter tray height. iOS is presented by a +/// native sheet with system detents in [ios_native_picker.dart]. const _sheetHeightFactor = 0.62; /// Opens the full emoji picker as a modal bottom sheet. @@ -33,12 +38,34 @@ void showEmojiPicker({ required BuildContext context, required void Function(String emoji) onSelect, VoidCallback? onDismiss, +}) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + unawaited( + _presentIosEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ), + ); + return; + } + + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); +} + +void _showFlutterEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, + VoidCallback? onDismiss, }) { showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, - backgroundColor: context.colors.surfaceContainerHighest, + showCloseButton: false, builder: (sheetContext) => EmojiPickerSheet( onSelect: (emoji) { Navigator.of(sheetContext).pop(); @@ -53,11 +80,41 @@ class EmojiPickerSheet extends HookConsumerWidget { const EmojiPickerSheet({super.key, required this.onSelect}); + @override + Widget build(BuildContext context, WidgetRef ref) { + return SizedBox( + height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, + child: _EmojiPickerContent(onSelect: onSelect), + ); + } +} + +class _EmojiPickerContent extends HookConsumerWidget { + const _EmojiPickerContent({required this.onSelect}); + + final void Function(String emoji) onSelect; + @override Widget build(BuildContext context, WidgetRef ref) { final dataset = ref.watch(emojiDatasetOrEmptyProvider); final customEmoji = ref.watch(customEmojiListProvider); final recent = ref.watch(recentEmojiProvider); + final prefs = ref.read(savedPrefsProvider); + final skinTone = useState( + _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), + ); + + void selectSkinTone(int value) { + final next = _validSkinTone(value); + if (skinTone.value == next) return; + skinTone.value = next; + unawaited(prefs.setInt(_emojiSkinTonePrefsKey, next)); + } + + final visibleDataset = useMemoized( + () => _datasetForSkinTone(dataset, skinTone.value), + [dataset, skinTone.value], + ); final searchController = useTextEditingController(); final query = useState(''); @@ -74,16 +131,17 @@ class EmojiPickerSheet extends HookConsumerWidget { final sections = useMemoized( () => _buildSections( - dataset: dataset, + dataset: visibleDataset, + sourceDataset: dataset, customEmoji: customEmoji, recent: recent, onSelect: select, ), - [dataset, customEmoji, recent], + [visibleDataset, dataset, customEmoji, recent], ); final offsets = useMemoized(() => _sectionOffsets(sections), [sections]); - final scrollController = useScrollController(); + // A notifier rather than state: the highlight changes on every scroll frame // and only the rail needs to hear about it. Rebuilding the sheet would // rebuild the grid underneath it. @@ -119,9 +177,9 @@ class EmojiPickerSheet extends HookConsumerWidget { // while the sheet animates. final results = useMemoized( () => isSearching - ? searchEmoji(trimmedQuery, dataset.all) + ? searchEmoji(trimmedQuery, visibleDataset.all) : const [], - [trimmedQuery, dataset], + [trimmedQuery, visibleDataset], ); final customResults = useMemoized( () => isSearching @@ -134,37 +192,48 @@ class EmojiPickerSheet extends HookConsumerWidget { [trimmedQuery, customEmoji], ); - return SizedBox( - height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, - child: Column( - children: [ - _EmojiSearchField(controller: searchController), - if (!isSearching && sections.isNotEmpty) - ValueListenableBuilder( - valueListenable: activeSection, - builder: (context, active, _) => _CategoryRail( - sections: sections, - activeIndex: active, - onSelect: jumpToSection, + return Column( + children: [ + LayoutBuilder( + builder: (context, constraints) => BuzzSheetHeader( + showDragHandle: true, + leading: SizedBox( + width: constraints.maxWidth - Grid.gutter * 2 - 44 - Grid.xxs, + child: _EmojiSearchField( + controller: searchController, + padding: EdgeInsets.zero, ), ), - Divider(height: 1, color: context.colors.outlineVariant), - Expanded( - child: dataset.isEmpty && customEmoji.isEmpty - ? const Center(child: CircularProgressIndicator()) - : isSearching - ? _EmojiSearchResults( - entries: results, - customEmoji: customResults, - onSelect: select, - ) - : _ContinuousEmojiGrid( - sections: sections, - controller: scrollController, - ), ), - ], - ), + ), + if (!isSearching && sections.isNotEmpty) + ValueListenableBuilder( + valueListenable: activeSection, + builder: (context, active, _) => _CategoryRail( + sections: sections, + activeIndex: active, + onSelect: jumpToSection, + skinTone: skinTone.value, + onSkinToneChanged: selectSkinTone, + ), + ), + Divider(height: 1, color: context.colors.outlineVariant), + Expanded( + child: dataset.isEmpty && customEmoji.isEmpty + ? const Center(child: CircularProgressIndicator()) + : isSearching + ? _EmojiSearchResults( + entries: results, + customEmoji: customResults, + onSelect: select, + controller: scrollController, + ) + : _ContinuousEmojiGrid( + sections: sections, + controller: scrollController, + ), + ), + ], ); } } @@ -177,6 +246,7 @@ class EmojiPickerSheet extends HookConsumerWidget { /// nowhere. List<_EmojiSection> _buildSections({ required EmojiDataset dataset, + required EmojiDataset sourceDataset, required List customEmoji, required List recent, required void Function(String emoji) onSelect, @@ -186,6 +256,7 @@ List<_EmojiSection> _buildSections({ final recentTiles = _resolveRecentTiles( recent: recent, dataset: dataset, + sourceDataset: sourceDataset, customEmoji: customEmoji, onSelect: onSelect, ); @@ -246,15 +317,18 @@ List<_EmojiSection> _buildSections({ List _resolveRecentTiles({ required List recent, required EmojiDataset dataset, + required EmojiDataset sourceDataset, required List customEmoji, required void Function(String emoji) onSelect, }) { final customByShortcode = { for (final emoji in customEmoji) emoji.shortcode.toLowerCase(): emoji, }; - final entriesByNative = { - for (final entry in dataset.all) entry.native: entry, + final sourceEntriesByNative = { + for (final entry in sourceDataset.all) entry.native: entry, }; + final visibleEntriesById = {for (final entry in dataset.all) entry.id: entry}; + final seenStandardIds = {}; final tiles = []; for (final item in recent) { @@ -272,7 +346,9 @@ List _resolveRecentTiles({ ); continue; } - final entry = entriesByNative[value]; + final sourceEntry = sourceEntriesByNative[value]; + if (sourceEntry == null || !seenStandardIds.add(sourceEntry.id)) continue; + final entry = visibleEntriesById[sourceEntry.id]; if (entry == null) continue; tiles.add( _EmojiTile( @@ -284,3 +360,37 @@ List _resolveRecentTiles({ } return tiles; } + +/// Project the dataset to one visible tile per shortcode. Emoji that support +/// skin tones use the selected variant; everything else keeps its default. +EmojiDataset _datasetForSkinTone(EmojiDataset dataset, int skinTone) { + if (dataset.isEmpty) return dataset; + final categories = []; + final all = []; + + for (final category in dataset.categories) { + final variantsById = >{}; + for (final entry in category.emoji) { + variantsById.putIfAbsent(entry.id, () => []).add(entry); + } + final visible = []; + for (final variants in variantsById.values) { + final selected = variants.firstWhere( + (entry) => entry.skinIndex == skinTone, + orElse: () => variants.firstWhere( + (entry) => entry.skinIndex == 0, + orElse: () => variants.first, + ), + ); + visible.add(selected); + all.add(selected); + } + categories.add(EmojiCategory(id: category.id, emoji: visible)); + } + + return EmojiDataset( + categories: categories, + all: all, + nativeToShortcode: dataset.nativeToShortcode, + ); +} diff --git a/mobile/lib/features/channels/emoji_picker/category_rail.dart b/mobile/lib/features/channels/emoji_picker/category_rail.dart index 50df1406886..92d8806f08d 100644 --- a/mobile/lib/features/channels/emoji_picker/category_rail.dart +++ b/mobile/lib/features/channels/emoji_picker/category_rail.dart @@ -18,6 +18,20 @@ IconData _categoryIcon(String categoryId) => switch (categoryId) { /// 18px icon it holds. const _railHeight = 36.0; +const _emojiSkinTonePrefsKey = 'buzz.emoji-picker.skin-tone.v1'; + +const _skinTones = [ + (label: 'Default', color: Color(0xFFFFC93A)), + (label: 'Light', color: Color(0xFFFFDAB7)), + (label: 'Medium-light', color: Color(0xFFE7B98F)), + (label: 'Medium', color: Color(0xFFC88C61)), + (label: 'Medium-dark', color: Color(0xFFA46134)), + (label: 'Dark', color: Color(0xFF5D4437)), +]; + +int _validSkinTone(int? value) => + value != null && value >= 0 && value < _skinTones.length ? value : 0; + /// Category selector: one icon per section of the continuous grid, in scroll /// order. Tapping jumps to that section; scrolling moves the highlight. /// @@ -28,11 +42,15 @@ class _CategoryRail extends StatelessWidget { final List<_EmojiSection> sections; final int activeIndex; final ValueChanged onSelect; + final int skinTone; + final ValueChanged onSkinToneChanged; const _CategoryRail({ required this.sections, required this.activeIndex, required this.onSelect, + required this.skinTone, + required this.onSkinToneChanged, }); @override @@ -52,6 +70,12 @@ class _CategoryRail extends StatelessWidget { onTap: () => onSelect(i), ), ), + Expanded( + child: _SkinToneSelector( + value: skinTone, + onChanged: onSkinToneChanged, + ), + ), ], ), ), @@ -59,6 +83,79 @@ class _CategoryRail extends StatelessWidget { } } +class _SkinToneSelector extends StatelessWidget { + const _SkinToneSelector({required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = _skinTones[_validSkinTone(value)]; + return PopupMenuButton( + key: const ValueKey('emoji-skin-tone-selector'), + initialValue: value, + tooltip: 'Skin tone', + position: PopupMenuPosition.under, + onSelected: onChanged, + itemBuilder: (context) => [ + for (final (index, tone) in _skinTones.indexed) + PopupMenuItem( + key: ValueKey('emoji-skin-tone-$index'), + value: index, + child: Row( + children: [ + _SkinToneDot(color: tone.color), + const SizedBox(width: Grid.xs), + Expanded(child: Text(tone.label)), + if (index == value) + Icon( + LucideIcons.check, + size: 18, + color: context.colors.primary, + ), + ], + ), + ), + ], + child: Semantics( + button: true, + label: 'Skin tone', + child: Center( + child: _SkinToneDot( + key: const ValueKey('emoji-skin-tone-dot-selected'), + color: selected.color, + ), + ), + ), + ); + } +} + +class _SkinToneDot extends StatelessWidget { + const _SkinToneDot({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: color, + gradient: LinearGradient( + colors: [Colors.white.withValues(alpha: 0.2), Colors.transparent], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + backgroundBlendMode: BlendMode.overlay, + shape: BoxShape.circle, + border: Border.all(color: Colors.black.withValues(alpha: 0.8)), + ), + child: const SizedBox.square(dimension: 16), + ); + } +} + class _CategoryIcon extends StatelessWidget { final IconData icon; final String tooltip; diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart index 354a31e44c6..7f13e3b1282 100644 --- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart +++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart @@ -92,7 +92,7 @@ class _EmojiTile extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - key: ValueKey('$keyPrefix-${entry.tileId}'), + key: ValueKey('$keyPrefix-${entry.id}'), onTap: onTap, behavior: HitTestBehavior.opaque, child: Semantics( @@ -196,11 +196,13 @@ class _EmojiSearchResults extends StatelessWidget { final List entries; final List customEmoji; final void Function(String emoji) onSelect; + final ScrollController controller; const _EmojiSearchResults({ required this.entries, required this.customEmoji, required this.onSelect, + required this.controller, }); @override @@ -214,6 +216,7 @@ class _EmojiSearchResults extends StatelessWidget { return CustomScrollView( key: const ValueKey('emoji-picker-search-results'), + controller: controller, slivers: [ if (customEmoji.isNotEmpty) ...[ const _SectionHeader(label: 'Custom'), @@ -279,7 +282,7 @@ class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { return Container( - color: context.colors.surfaceContainerHighest, + color: context.colors.surface, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), child: Text(label, style: _sectionLabelStyle(context)), diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart new file mode 100644 index 00000000000..4b2a8dac3fc --- /dev/null +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -0,0 +1,76 @@ +part of '../emoji_picker.dart'; + +const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); + +Future _presentIosEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, + VoidCallback? onDismiss, +}) async { + final container = ProviderScope.containerOf(context, listen: false); + final customEmoji = container.read(customEmojiListProvider); + final recent = container.read(recentEmojiProvider); + final prefs = container.read(savedPrefsProvider); + final colors = context.colors; + var dismissed = false; + + void finish() { + if (dismissed) return; + dismissed = true; + _nativeEmojiPickerChannel.setMethodCallHandler(null); + onDismiss?.call(); + } + + _nativeEmojiPickerChannel.setMethodCallHandler((call) async { + switch (call.method) { + case 'selected': + final emoji = call.arguments; + if (emoji is String && emoji.isNotEmpty) onSelect(emoji); + return; + case 'dismissed': + finish(); + return; + case 'skinToneChanged': + final value = call.arguments; + if (value is int) { + await prefs.setInt(_emojiSkinTonePrefsKey, _validSkinTone(value)); + } + return; + } + }); + + try { + final presented = await _nativeEmojiPickerChannel.invokeMethod( + 'present', + { + 'customEmoji': [ + for (final emoji in customEmoji) + {'shortcode': emoji.shortcode, 'url': emoji.url}, + ], + 'recent': [for (final entry in recent) entry.emoji], + 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), + 'surfaceColor': colors.surface.toARGB32(), + 'controlColor': colors.surfaceContainerHighest.toARGB32(), + 'textColor': colors.onSurface.toARGB32(), + 'secondaryTextColor': colors.onSurfaceVariant.toARGB32(), + 'accentColor': colors.primary.toARGB32(), + 'dividerColor': colors.outlineVariant.toARGB32(), + 'isDark': Theme.of(context).brightness == Brightness.dark, + }, + ); + if (presented == true) return; + } on MissingPluginException { + // Older builds keep the complete Flutter picker as a safe fallback. + } on PlatformException { + // A native presentation failure should not remove the emoji affordance. + } + + if (dismissed || !context.mounted) return; + dismissed = true; + _nativeEmojiPickerChannel.setMethodCallHandler(null); + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); +} diff --git a/mobile/lib/features/channels/emoji_picker/search_field.dart b/mobile/lib/features/channels/emoji_picker/search_field.dart index dd4c4ee9233..c61dd9d2cc8 100644 --- a/mobile/lib/features/channels/emoji_picker/search_field.dart +++ b/mobile/lib/features/channels/emoji_picker/search_field.dart @@ -8,68 +8,80 @@ part of '../emoji_picker.dart'; /// words and the OS mangles them. Flutter exposes the same switches directly. class _EmojiSearchField extends StatelessWidget { final TextEditingController controller; + final EdgeInsetsGeometry padding; - const _EmojiSearchField({required this.controller}); + const _EmojiSearchField({ + required this.controller, + this.padding = const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xxs, + ), + }); @override Widget build(BuildContext context) { final colors = context.colors; return Padding( - padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xxs), - child: TextField( - key: const ValueKey('emoji-picker-search'), - controller: controller, - autocorrect: false, - enableSuggestions: false, - textCapitalization: TextCapitalization.none, - textInputAction: TextInputAction.search, - style: searchInputTextStyle.copyWith(color: colors.onSurface), - decoration: InputDecoration( - hintText: 'Search emoji', - hintStyle: searchInputTextStyle.copyWith( - color: colors.onSurfaceVariant, - ), - prefixIcon: Icon( - LucideIcons.search, - size: 18, - color: colors.onSurfaceVariant, - ), - prefixIconConstraints: const BoxConstraints( - minWidth: Grid.md, - minHeight: Grid.md, - ), - suffixIcon: ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) { - if (value.text.isEmpty) return const SizedBox.shrink(); - return IconButton( - key: const ValueKey('emoji-picker-search-clear'), - onPressed: controller.clear, - icon: Icon( - LucideIcons.x, - size: 16, - color: colors.onSurfaceVariant, - ), - visualDensity: VisualDensity.compact, - tooltip: 'Clear search', - ); - }, - ), - filled: true, - fillColor: colors.surface, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: colors.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: colors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: colors.primary), + padding: padding, + child: SizedBox( + height: 44, + child: TextField( + key: const ValueKey('emoji-picker-search'), + controller: controller, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.search, + style: searchInputTextStyle.copyWith(color: colors.onSurface), + decoration: InputDecoration( + hintText: 'Search emoji', + hintStyle: searchInputTextStyle.copyWith( + color: colors.onSurfaceVariant, + ), + prefixIcon: Icon( + LucideIcons.search, + size: 18, + color: colors.onSurfaceVariant, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: Grid.md, + minHeight: Grid.md, + ), + suffixIcon: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return IconButton( + key: const ValueKey('emoji-picker-search-clear'), + onPressed: controller.clear, + icon: Icon( + LucideIcons.x, + size: 16, + color: colors.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + tooltip: 'Clear search', + ); + }, + ), + filled: true, + fillColor: colors.surface, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.full), + borderSide: BorderSide(color: colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.full), + borderSide: BorderSide(color: colors.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.full), + borderSide: BorderSide(color: colors.primary), + ), ), ), ), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index bf8086da4c7..a5bbea9ec93 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -6,8 +6,11 @@ import 'package:buzz/shared/emoji/emoji_data.dart'; import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/widget_helpers.dart'; @@ -41,11 +44,39 @@ final _dataset = () { ), _entry( 'point_up', - native: '\u{261D}\u{1F3FD}', + native: '\u{261D}\u{1F3FB}', categoryId: 'people', name: 'Index Pointing Up', skinIndex: 1, ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FC}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 2, + ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FD}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 3, + ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FE}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 4, + ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FF}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 5, + ), ]; final nature = [ _entry( @@ -96,6 +127,29 @@ const _customEmoji = [ CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'), ]; +const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); + +void _setMockNativeEmojiPickerHandler( + Future Function(MethodCall call)? handler, +) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeEmojiPickerChannel, handler); +} + +Future _sendNativeEmojiPickerCall( + WidgetTester tester, + String method, [ + Object? arguments, +]) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _nativeEmojiPickerChannel.name, + _nativeEmojiPickerChannel.codec.encodeMethodCall( + MethodCall(method, arguments), + ), + null, + ); +} + Future _prefs() { SharedPreferences.setMockInitialValues({}); return SharedPreferences.getInstance(); @@ -147,26 +201,113 @@ void main() { // shortcut into it, not a page switcher. final grid = find.byKey(const ValueKey('emoji-picker-grid')); expect(grid, findsOneWidget); + final sectionKeys = tester + .widget(grid) + .slivers + .whereType() + .map((sliver) => sliver.key); expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-grinning')), - ), - findsOneWidget, + sectionKeys, + containsAllInOrder(const [ + ValueKey('emoji-section-people'), + ValueKey('emoji-section-nature'), + ValueKey('emoji-section-custom'), + ]), ); + }); + + testWidgets( + 'search shares the sheet header with the shared close control', + (tester) async { + await _pumpPicker(tester, prefs: await _prefs()); + + final search = tester.getRect( + find.byKey(const ValueKey('emoji-picker-search')), + ); + final close = tester.getRect(find.byTooltip('Close sheet')); + + expect(close.size, const Size.square(44)); + expect(search.center.dy, close.center.dy); + expect(close.left - search.right, Grid.xxs); + }, + ); + + testWidgets('the Flutter picker keeps the established tray height', ( + tester, + ) async { + await _pumpPicker(tester, prefs: await _prefs()); + + final picker = find.byType(EmojiPickerSheet); + final context = tester.element(picker); expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-fire')), + tester.getSize(picker).height, + closeTo(MediaQuery.sizeOf(context).height * 0.62, 0.5), + ); + expect(find.byType(DraggableScrollableSheet), findsNothing); + }); + + testWidgets('the Flutter search field is a full pill', (tester) async { + await _pumpPicker(tester, prefs: await _prefs()); + + final field = tester.widget( + find.byKey(const ValueKey('emoji-picker-search')), + ); + final border = field.decoration!.border! as OutlineInputBorder; + expect(border.borderRadius, BorderRadius.circular(Radii.full)); + }); + + testWidgets('uses the shared sheet surface instead of a picker override', ( + tester, + ) async { + final prefs = await _prefs(); + final theme = AppTheme.light().copyWith( + colorScheme: lightColorScheme.copyWith( + surfaceContainerHighest: Colors.grey, + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.green, ), - findsOneWidget, ); - expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + customEmojiListProvider.overrideWithValue(_customEmoji), + ], + child: MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), ), - findsOneWidget, + ); + + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + + final ancestorMaterials = find + .ancestor( + of: find.byType(EmojiPickerSheet), + matching: find.byType(Material), + ) + .evaluate() + .map((element) => element.widget as Material); + expect( + ancestorMaterials.map((material) => material.color), + contains(Colors.green), + ); + expect( + ancestorMaterials.map((material) => material.color), + isNot(contains(Colors.grey)), ); }); @@ -174,21 +315,27 @@ void main() { await _pumpPicker(tester, prefs: await _prefs()); // The rail used to be a short left-aligned strip. Every section now gets - // one evenly-sized slot across the same width the search field spans. - final searchField = tester.getRect( - find.byKey(const ValueKey('emoji-picker-search')), - ); + // one evenly-sized slot across the tray, while search shares its row with + // the close control above. + final picker = tester.getRect(find.byType(EmojiPickerSheet)); final people = tester.getRect(find.byTooltip('Smileys & People')); final nature = tester.getRect(find.byTooltip('Animals & Nature')); final custom = tester.getRect(find.byTooltip('Custom')); + final skinTone = tester.getRect(find.byTooltip('Skin tone')); expect(nature.left, greaterThan(people.left)); expect(custom.left, greaterThan(nature.left)); expect(people.width, closeTo(nature.width, 0.5)); expect(people.width, closeTo(custom.width, 0.5)); - // First slot starts and last slot ends on the search field's edges. - expect(people.left, closeTo(searchField.left, 0.5)); - expect(custom.right, closeTo(searchField.right, 0.5)); + expect(people.width, closeTo(skinTone.width, 0.5)); + expect(people.left, closeTo(picker.left + Grid.gutter, 0.5)); + expect(skinTone.right, closeTo(picker.right - Grid.gutter, 0.5)); + expect( + tester.getSize( + find.byKey(const ValueKey('emoji-skin-tone-dot-selected')), + ), + const Size.square(16), + ); }); testWidgets('tapping the rail scrolls the grid instead of replacing it', ( @@ -223,6 +370,8 @@ void main() { await _pumpPicker(tester, prefs: await _prefs()); expect(find.byTooltip('Custom'), findsOneWidget); + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), findsOneWidget, @@ -236,14 +385,16 @@ void main() { // A community's own emoji used to get their own looser 6-per-row grid, // which read as a different component bolted onto the sheet. - final native = tester.getRect( - find.byKey(const ValueKey('emoji-tile-fire')), - ); + final nativeWidth = tester + .getRect(find.byKey(const ValueKey('emoji-tile-grinning'))) + .width; + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); final custom = tester.getRect( find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), ); - expect(custom.width, closeTo(native.width, 0.5)); - expect(custom.height, closeTo(native.height, 0.5)); + expect(custom.width, closeTo(nativeWidth, 0.5)); + expect(custom.height, closeTo(40, 0.5)); }); testWidgets('typing filters across the standard and custom sets', ( @@ -326,16 +477,27 @@ void main() { testWidgets('a standard emoji emits its glyph', (tester) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); + await tester.tap(find.byTooltip('Animals & Nature')); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('emoji-tile-fire'))); await tester.pumpAndSettle(); expect(selected, ['\u{1F525}']); }); - testWidgets('a skin-tone variant is selectable', (tester) async { + testWidgets('skin tone choice shows and emits one selected variant', ( + tester, + ) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); - await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up-1'))); + expect(find.byKey(const ValueKey('emoji-tile-point_up')), findsOneWidget); + expect(find.byKey(const ValueKey('emoji-tile-point_up-1')), findsNothing); + + await tester.tap(find.byTooltip('Skin tone')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up'))); await tester.pumpAndSettle(); expect(selected, ['\u{261D}\u{1F3FD}']); @@ -344,6 +506,8 @@ void main() { testWidgets('a custom emoji emits :shortcode:', (tester) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); await tester.tap( find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), ); @@ -358,6 +522,8 @@ void main() { final prefs = await _prefs(); final selected = await _pumpPicker(tester, prefs: prefs); + await tester.tap(find.byTooltip('Animals & Nature')); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('emoji-tile-fire'))); await tester.pumpAndSettle(); @@ -392,6 +558,117 @@ void main() { }); }); + group('iOS native emoji picker', () { + testWidgets('passes shared theme and custom emoji into the native sheet', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + MethodCall? presentation; + final selected = []; + var dismissals = 0; + _setMockNativeEmojiPickerHandler((call) async { + presentation = call; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + customEmojiListProvider.overrideWithValue(_customEmoji), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: selected.add, + onDismiss: () => dismissals += 1, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + + expect(presentation?.method, 'present'); + final arguments = presentation?.arguments as Map; + expect(arguments['surfaceColor'], lightColorScheme.surface.toARGB32()); + expect(arguments['skinTone'], 0); + expect(arguments['customEmoji'], [ + { + 'shortcode': 'partyparrot', + 'url': 'https://example.test/parrot.gif', + }, + ]); + expect(find.byType(EmojiPickerSheet), findsNothing); + + await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4); + await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); + await _sendNativeEmojiPickerCall(tester, 'dismissed'); + expect(selected, ['\u{1F525}']); + expect(dismissals, 1); + expect(prefs.getInt('buzz.emoji-picker.skin-tone.v1'), 4); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('falls back to the Flutter picker when native cannot present', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + _setMockNativeEmojiPickerHandler((_) async => false); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + customEmojiListProvider.overrideWithValue(_customEmoji), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + expect(find.byType(EmojiPickerSheet), findsOneWidget); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + }); + group('recent emoji ranking', () { test('promotes by use count, breaking ties on recency', () { var entries = []; From beb335dc78add7d585c137f1ea7e3a5d8f1fe7c5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 14 Aug 2026 11:30:44 +0100 Subject: [PATCH 02/12] Fix Android skin tone colors Signed-off-by: kenny lopez --- .../channels/emoji_picker/category_rail.dart | 46 +++++++++++++------ .../features/channels/emoji_picker_test.dart | 39 ++++++++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/channels/emoji_picker/category_rail.dart b/mobile/lib/features/channels/emoji_picker/category_rail.dart index 92d8806f08d..e654134ec66 100644 --- a/mobile/lib/features/channels/emoji_picker/category_rail.dart +++ b/mobile/lib/features/channels/emoji_picker/category_rail.dart @@ -105,7 +105,10 @@ class _SkinToneSelector extends StatelessWidget { value: index, child: Row( children: [ - _SkinToneDot(color: tone.color), + _SkinToneDot( + key: ValueKey('emoji-skin-tone-dot-$index'), + color: tone.color, + ), const SizedBox(width: Grid.xs), Expanded(child: Text(tone.label)), if (index == value) @@ -139,19 +142,36 @@ class _SkinToneDot extends StatelessWidget { @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: color, - gradient: LinearGradient( - colors: [Colors.white.withValues(alpha: 0.2), Colors.transparent], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - backgroundBlendMode: BlendMode.overlay, - shape: BoxShape.circle, - border: Border.all(color: Colors.black.withValues(alpha: 0.8)), + return SizedBox.square( + dimension: 16, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ClipOval( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0.2), + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black.withValues(alpha: 0.8)), + ), + ), + ], ), - child: const SizedBox.square(dimension: 16), ); } } diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index a5bbea9ec93..4635f1f8b1a 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -503,6 +503,45 @@ void main() { expect(selected, ['\u{261D}\u{1F3FD}']); }); + testWidgets('skin tone choices paint their colors on a solid layer', ( + tester, + ) async { + await _pumpPicker(tester, prefs: await _prefs()); + + await tester.tap(find.byTooltip('Skin tone')); + await tester.pumpAndSettle(); + + const expectedColors = [ + Color(0xFFFFC93A), + Color(0xFFFFDAB7), + Color(0xFFE7B98F), + Color(0xFFC88C61), + Color(0xFFA46134), + Color(0xFF5D4437), + ]; + for (final (index, expectedColor) in expectedColors.indexed) { + final decorations = tester + .widgetList( + find.descendant( + of: find.byKey(ValueKey('emoji-skin-tone-dot-$index')), + matching: find.byType(DecoratedBox), + ), + ) + .map((widget) => widget.decoration) + .whereType(); + + expect( + decorations.any( + (decoration) => + decoration.color == expectedColor && + decoration.gradient == null && + decoration.backgroundBlendMode == null, + ), + isTrue, + ); + } + }); + testWidgets('a custom emoji emits :shortcode:', (tester) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); From 2fbf30b0de1056796f08609f8974b2bdf4a9ce90 Mon Sep 17 00:00:00 2001 From: Watcher Date: Fri, 14 Aug 2026 15:26:38 +0100 Subject: [PATCH 03/12] Harden native custom emoji loading Co-authored-by: Watcher Signed-off-by: Watcher Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeEmojiPicker.swift | 81 ++++++++++++++--- .../lib/features/channels/emoji_picker.dart | 1 + .../emoji_picker/ios_native_picker.dart | 10 ++- .../features/channels/emoji_picker_test.dart | 90 ++++++++++++++++++- 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift index c5783030b48..681ca56ee98 100644 --- a/mobile/ios/Runner/NativeEmojiPicker.swift +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -46,6 +46,7 @@ private struct NativeEmojiItem: Identifiable, Hashable { let glyph: String? let skinVariants: [String] let imageURL: URL? + let imageHeaders: [String: String] } private struct NativeEmojiSkinTone: Identifiable { @@ -161,7 +162,8 @@ private enum NativeEmojiPickerDataLoader { keywords: keywords, glyph: defaultGlyph, skinVariants: glyphs, - imageURL: nil + imageURL: nil, + imageHeaders: [:] ) items.append(item) standardItems.append(item) @@ -189,6 +191,7 @@ private enum NativeEmojiPickerDataLoader { else { return nil } + let headers = (raw["headers"] as? [String: String]) ?? [:] return NativeEmojiItem( id: "custom-\(shortcode)", shortcode: shortcode, @@ -197,7 +200,8 @@ private enum NativeEmojiPickerDataLoader { keywords: [], glyph: nil, skinVariants: [], - imageURL: url + imageURL: url, + imageHeaders: headers ) } let customByValue = Dictionary( @@ -666,17 +670,11 @@ private struct NativeEmojiPickerView: View { } label: { Group { if let url = item.imageURL { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - case .failure: - Image(systemName: "sparkles") - .foregroundStyle(Color(uiColor: appearance.secondaryText)) - default: - ProgressView().controlSize(.mini) - } - } + NativeEmojiRemoteImage( + url: url, + headers: item.imageHeaders, + fallbackColor: appearance.secondaryText + ) .frame(width: 28, height: 28) } else { Text(value).font(.system(size: 28)) @@ -699,6 +697,63 @@ private struct NativeEmojiPickerView: View { } } +private struct NativeEmojiRemoteImage: View { + let url: URL + let headers: [String: String] + let fallbackColor: UIColor + + @State private var phase: Phase = .loading + + private enum Phase { + case loading + case success(UIImage) + case failure + } + + var body: some View { + Group { + switch phase { + case .loading: + ProgressView().controlSize(.mini) + case .success(let image): + Image(uiImage: image).resizable().scaledToFit() + case .failure: + Image(systemName: "sparkles") + .foregroundStyle(Color(uiColor: fallbackColor)) + } + } + .task(id: requestIdentity) { + var request = URLRequest(url: url) + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode), + let image = UIImage(data: data) + else { + phase = .failure + return + } + phase = .success(image) + } catch { + if !Task.isCancelled { phase = .failure } + } + } + } + + private var requestIdentity: String { + let headerIdentity = + headers + .sorted { $0.key < $1.key } + .map { "\($0.key):\($0.value)" } + .joined(separator: "\n") + return "\(url.absoluteString)\n\(headerIdentity)" + } +} + final class NativeEmojiPickerCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index 264624b00a1..81d889646d4 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -14,6 +14,7 @@ import '../../shared/emoji/emoji_data.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_search.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_sheet_header.dart'; import '../../shared/widgets/modal_presentation.dart'; diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart index 4b2a8dac3fc..b5f2f93e169 100644 --- a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -8,8 +8,10 @@ Future _presentIosEmojiPicker({ VoidCallback? onDismiss, }) async { final container = ProviderScope.containerOf(context, listen: false); - final customEmoji = container.read(customEmojiListProvider); + final customEmoji = await container.read(customEmojiPaletteProvider.future); + if (!context.mounted) return; final recent = container.read(recentEmojiProvider); + final mediaAuth = container.read(mediaGetAuthServiceProvider); final prefs = container.read(savedPrefsProvider); final colors = context.colors; var dismissed = false; @@ -45,7 +47,11 @@ Future _presentIosEmojiPicker({ { 'customEmoji': [ for (final emoji in customEmoji) - {'shortcode': emoji.shortcode, 'url': emoji.url}, + { + 'shortcode': emoji.shortcode, + 'url': emoji.url, + 'headers': mediaAuth.headersFor(emoji.url), + }, ], 'recent': [for (final entry in recent) entry.emoji], 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 4635f1f8b1a..4fedd99aa0b 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/recent_emoji_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; @@ -12,6 +14,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nostr/nostr.dart' as nostr; import '../../helpers/widget_helpers.dart'; @@ -126,6 +129,21 @@ final _tallDataset = () { const _customEmoji = [ CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'), ]; +const _relayCustomEmoji = [ + CustomEmoji( + shortcode: 'buzzbee', + url: 'https://relay.example/media/buzzbee.png', + ), +]; + +class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier { + _FakeCustomEmojiPaletteNotifier(this.palette); + + final Future> palette; + + @override + Future> build() => palette; +} const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); @@ -604,6 +622,10 @@ void main() { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final prefs = await _prefs(); + final mediaAuth = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ); MethodCall? presentation; final selected = []; var dismissals = 0; @@ -618,7 +640,12 @@ void main() { overrides: [ savedPrefsProvider.overrideWithValue(prefs), myPubkeyProvider.overrideWithValue('self'), - customEmojiListProvider.overrideWithValue(_customEmoji), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier( + Future.value([..._customEmoji, ..._relayCustomEmoji]), + ), + ), + mediaGetAuthServiceProvider.overrideWithValue(mediaAuth), ], child: MaterialApp( theme: AppTheme.light(), @@ -649,6 +676,12 @@ void main() { { 'shortcode': 'partyparrot', 'url': 'https://example.test/parrot.gif', + 'headers': {}, + }, + { + 'shortcode': 'buzzbee', + 'url': 'https://relay.example/media/buzzbee.png', + 'headers': {'Authorization': startsWith('Nostr ')}, }, ]); expect(find.byType(EmojiPickerSheet), findsNothing); @@ -665,6 +698,56 @@ void main() { } }); + testWidgets('awaits the custom emoji palette before presenting', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + final palette = Completer>(); + MethodCall? presentation; + _setMockNativeEmojiPickerHandler((call) async { + presentation = call; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier(palette.future), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + expect(presentation, isNull); + + palette.complete(_customEmoji); + await tester.pumpAndSettle(); + expect(presentation?.method, 'present'); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('falls back to the Flutter picker when native cannot present', ( tester, ) async { @@ -680,7 +763,10 @@ void main() { savedPrefsProvider.overrideWithValue(prefs), myPubkeyProvider.overrideWithValue('self'), emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), - customEmojiListProvider.overrideWithValue(_customEmoji), + customEmojiPaletteProvider.overrideWith( + () => + _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)), + ), ], child: MaterialApp( theme: AppTheme.light(), From 93bfdf9fb27522cb03c7dc9761b8540b94559153 Mon Sep 17 00:00:00 2001 From: Watcher Date: Fri, 14 Aug 2026 16:00:03 +0100 Subject: [PATCH 04/12] Bound native emoji media loading Co-authored-by: Watcher Signed-off-by: Watcher Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeEmojiPicker.swift | 100 ++++++++++++++---- .../emoji_picker/ios_native_picker.dart | 17 +-- .../features/channels/emoji_picker_test.dart | 27 ++++- 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift index 681ca56ee98..7555c0c4b11 100644 --- a/mobile/ios/Runner/NativeEmojiPicker.swift +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -1,4 +1,5 @@ import Flutter +import ImageIO import SwiftUI import UIKit @@ -46,7 +47,6 @@ private struct NativeEmojiItem: Identifiable, Hashable { let glyph: String? let skinVariants: [String] let imageURL: URL? - let imageHeaders: [String: String] } private struct NativeEmojiSkinTone: Identifiable { @@ -162,8 +162,7 @@ private enum NativeEmojiPickerDataLoader { keywords: keywords, glyph: defaultGlyph, skinVariants: glyphs, - imageURL: nil, - imageHeaders: [:] + imageURL: nil ) items.append(item) standardItems.append(item) @@ -191,7 +190,6 @@ private enum NativeEmojiPickerDataLoader { else { return nil } - let headers = (raw["headers"] as? [String: String]) ?? [:] return NativeEmojiItem( id: "custom-\(shortcode)", shortcode: shortcode, @@ -200,8 +198,7 @@ private enum NativeEmojiPickerDataLoader { keywords: [], glyph: nil, skinVariants: [], - imageURL: url, - imageHeaders: headers + imageURL: url ) } let customByValue = Dictionary( @@ -672,7 +669,6 @@ private struct NativeEmojiPickerView: View { if let url = item.imageURL { NativeEmojiRemoteImage( url: url, - headers: item.imageHeaders, fallbackColor: appearance.secondaryText ) .frame(width: 28, height: 28) @@ -699,10 +695,11 @@ private struct NativeEmojiPickerView: View { private struct NativeEmojiRemoteImage: View { let url: URL - let headers: [String: String] let fallbackColor: UIColor @State private var phase: Phase = .loading + private static let maxDownloadBytes = 10 * 1024 * 1024 + private static let maxThumbnailPixels = 84 private enum Phase { case loading @@ -723,20 +720,47 @@ private struct NativeEmojiRemoteImage: View { } } .task(id: requestIdentity) { - var request = URLRequest(url: url) - for (name, value) in headers { - request.setValue(value, forHTTPHeaderField: name) - } do { - let (data, response) = try await URLSession.shared.data(for: request) + let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders( + for: url + ) + var request = URLRequest(url: url) + for (name, value) in requestHeaders { + request.setValue(value, forHTTPHeaderField: name) + } + let (bytes, response) = try await URLSession.shared.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode), - let image = UIImage(data: data) + (200..<300).contains(httpResponse.statusCode) else { phase = .failure return } + if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let byteCount = Int(contentLength), + byteCount > Self.maxDownloadBytes + { + phase = .failure + return + } + var data = Data() + let expected = httpResponse.expectedContentLength + if expected > 0 { + data.reserveCapacity( + Int(min(expected, Int64(Self.maxDownloadBytes))) + ) + } + for try await byte in bytes { + guard data.count < Self.maxDownloadBytes else { + phase = .failure + return + } + data.append(byte) + } + guard let image = Self.thumbnail(from: data) else { + phase = .failure + return + } phase = .success(image) } catch { if !Task.isCancelled { phase = .failure } @@ -744,20 +768,40 @@ private struct NativeEmojiRemoteImage: View { } } + private static func thumbnail(from data: Data) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels, + kCGImageSourceShouldCacheImmediately: true, + ] + guard + let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) + else { + return nil + } + return UIImage(cgImage: image) + } + private var requestIdentity: String { - let headerIdentity = - headers - .sorted { $0.key < $1.key } - .map { "\($0.key):\($0.value)" } - .joined(separator: "\n") - return "\(url.absoluteString)\n\(headerIdentity)" + url.absoluteString } } +private struct NativeEmojiMediaHeaderError: Error {} + final class NativeEmojiPickerCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { private let channel: FlutterMethodChannel + private static weak var activeCoordinator: NativeEmojiPickerCoordinator? private weak var parentViewController: UIViewController? private weak var presentedController: UIViewController? private var didNotifyDismissal = false @@ -772,11 +816,25 @@ final class NativeEmojiPickerCoordinator: NSObject, ) self.parentViewController = parentViewController super.init() + Self.activeCoordinator = self channel.setMethodCallHandler { [weak self] call, result in self?.handle(call, result: result) } } + static func mediaHeaders(for url: URL) async throws -> [String: String] { + guard let channel = activeCoordinator?.channel else { return [:] } + return try await withCheckedThrowingContinuation { continuation in + channel.invokeMethod("mediaHeaders", arguments: url.absoluteString) { result in + if result is FlutterError { + continuation.resume(throwing: NativeEmojiMediaHeaderError()) + return + } + continuation.resume(returning: result as? [String: String] ?? [:]) + } + } + } + private func handle( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart index b5f2f93e169..9f9ff7cc25f 100644 --- a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -25,19 +25,24 @@ Future _presentIosEmojiPicker({ _nativeEmojiPickerChannel.setMethodCallHandler((call) async { switch (call.method) { + case 'mediaHeaders': + final url = call.arguments; + return url is String + ? mediaAuth.headersFor(url) + : const {}; case 'selected': final emoji = call.arguments; if (emoji is String && emoji.isNotEmpty) onSelect(emoji); - return; + return null; case 'dismissed': finish(); - return; + return null; case 'skinToneChanged': final value = call.arguments; if (value is int) { await prefs.setInt(_emojiSkinTonePrefsKey, _validSkinTone(value)); } - return; + return null; } }); @@ -47,11 +52,7 @@ Future _presentIosEmojiPicker({ { 'customEmoji': [ for (final emoji in customEmoji) - { - 'shortcode': emoji.shortcode, - 'url': emoji.url, - 'headers': mediaAuth.headersFor(emoji.url), - }, + {'shortcode': emoji.shortcode, 'url': emoji.url}, ], 'recent': [for (final entry in recent) entry.emoji], 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 4fedd99aa0b..e2e29224c0f 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -154,18 +154,23 @@ void _setMockNativeEmojiPickerHandler( .setMockMethodCallHandler(_nativeEmojiPickerChannel, handler); } -Future _sendNativeEmojiPickerCall( +Future _sendNativeEmojiPickerCall( WidgetTester tester, String method, [ Object? arguments, ]) async { + final response = Completer(); await tester.binding.defaultBinaryMessenger.handlePlatformMessage( _nativeEmojiPickerChannel.name, _nativeEmojiPickerChannel.codec.encodeMethodCall( MethodCall(method, arguments), ), - null, + response.complete, ); + final envelope = await response.future; + return envelope == null + ? null + : _nativeEmojiPickerChannel.codec.decodeEnvelope(envelope); } Future _prefs() { @@ -676,16 +681,30 @@ void main() { { 'shortcode': 'partyparrot', 'url': 'https://example.test/parrot.gif', - 'headers': {}, }, { 'shortcode': 'buzzbee', 'url': 'https://relay.example/media/buzzbee.png', - 'headers': {'Authorization': startsWith('Nostr ')}, }, ]); expect(find.byType(EmojiPickerSheet), findsNothing); + final externalHeaders = await _sendNativeEmojiPickerCall( + tester, + 'mediaHeaders', + 'https://example.test/parrot.gif', + ); + expect(externalHeaders, {}); + final relayHeaders = await _sendNativeEmojiPickerCall( + tester, + 'mediaHeaders', + 'https://relay.example/media/buzzbee.png', + ); + expect( + (relayHeaders as Map)['Authorization'], + startsWith('Nostr '), + ); + await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4); await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); await _sendNativeEmojiPickerCall(tester, 'dismissed'); From 3589cb669bea6303432beffd013a91a7e18a703d Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Mon, 17 Aug 2026 08:21:00 +0100 Subject: [PATCH 05/12] Harden native emoji picker lifecycle and track category on scroll Fixes the review findings on the iOS native emoji picker without changing its authored look or interaction flow. - A custom-emoji palette fetch error no longer strands the composer: the failed await falls back to the Flutter picker while the context is mounted, so onDismiss still runs and isEmojiPickerOpen is cleared. - A reentrant open is coalesced by a presentation guard so it cannot replace the live sheet's method-call handler and hijack the original owner's select/dismiss callbacks; native present() now returns false when a sheet is already up instead of a misleading true. - The category rail follows manual scrolling via section-header offsets and exposes the isSelected VoiceOver trait; selection logic is extracted to a pure NativeEmojiCategoryTracker for unit tests. - Adds Dart regressions for the palette-error fallback and reentrancy, and RunnerTests for the scroll tracker. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeEmojiPicker.swift | 74 ++++++++- mobile/ios/RunnerTests/RunnerTests.swift | 53 +++++++ .../emoji_picker/ios_native_picker.dart | 47 +++++- .../features/channels/emoji_picker_test.dart | 148 ++++++++++++++++++ 4 files changed, 315 insertions(+), 7 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift index 7555c0c4b11..21ca0ca1497 100644 --- a/mobile/ios/Runner/NativeEmojiPicker.swift +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -372,6 +372,43 @@ private enum NativeEmojiSearch { } } +/// The top offset of each pinned section header, keyed by section id, reported +/// up from the scrolling grid so the rail can follow manual scrolling. +private struct NativeEmojiSectionOffsetsKey: PreferenceKey { + static let defaultValue: [String: CGFloat] = [:] + + static func reduce( + value: inout [String: CGFloat], + nextValue: () -> [String: CGFloat] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + +/// Pure selection logic: the highlighted section is the last one whose header +/// has scrolled to or above the top of the viewport. Extracted so the +/// scroll-tracking behaviour can be unit-tested without a live scroll view. +enum NativeEmojiCategoryTracker { + static func selectedSectionID( + order: [String], + offsets: [String: CGFloat], + viewportTop: CGFloat + ) -> String? { + var selected: String? + for id in order { + guard let top = offsets[id] else { continue } + // A small tolerance keeps the header that is flush with the top pinned as + // selected rather than flickering to the next section. + if top <= viewportTop + 1 { + selected = id + } else { + break + } + } + return selected ?? order.first + } +} + private struct NativeEmojiPickerView: View { let data: NativeEmojiPickerData let appearance: NativeEmojiPickerAppearance @@ -388,6 +425,8 @@ private struct NativeEmojiPickerView: View { count: 8 ) + private let sectionListSpace = "buzz.emoji.sectionList" + init( data: NativeEmojiPickerData, appearance: NativeEmojiPickerAppearance, @@ -496,6 +535,9 @@ private struct NativeEmojiPickerView: View { .frame(maxWidth: .infinity) .buttonStyle(.plain) .accessibilityLabel(section.title) + .accessibilityAddTraits( + selectedSectionID == section.id ? .isSelected : [] + ) } Divider() .frame(height: 24) @@ -591,7 +633,7 @@ private struct NativeEmojiPickerView: View { private var pickerContent: some View { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedQuery.isEmpty { - sectionList(data.sections) + sectionList(data.sections, tracksSelection: true) } else { let custom = NativeEmojiSearch.results( query: trimmedQuery, @@ -625,12 +667,15 @@ private struct NativeEmojiPickerView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .foregroundStyle(Color(uiColor: appearance.secondaryText)) } else { - sectionList(sections) + sectionList(sections, tracksSelection: false) } } } - private func sectionList(_ sections: [NativeEmojiSection]) -> some View { + private func sectionList( + _ sections: [NativeEmojiSection], + tracksSelection: Bool + ) -> some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(sections) { section in @@ -651,13 +696,32 @@ private struct NativeEmojiPickerView: View { .padding(.horizontal, 16) .frame(height: 30) .background(Color(uiColor: appearance.surface)) + .background(sectionOffsetReporter(id: section.id)) .id("section-\(section.id)") } } } .padding(.bottom, 8) } + .coordinateSpace(name: sectionListSpace) .scrollDismissesKeyboard(.interactively) + .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in + guard tracksSelection else { return } + selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID( + order: data.sections.map(\.id), + offsets: offsets, + viewportTop: 0 + ) + } + } + + private func sectionOffsetReporter(id: String) -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [id: geometry.frame(in: .named(sectionListSpace)).minY] + ) + } } private func emojiButton(_ item: NativeEmojiItem) -> some View { @@ -861,7 +925,9 @@ final class NativeEmojiPickerCoordinator: NSObject, @MainActor private func present(arguments: [String: Any]) -> Bool { - guard presentedController == nil else { return true } + // A sheet is already owned by an earlier caller; report busy rather than a + // successful presentation so the caller does not treat this as its own. + guard presentedController == nil else { return false } guard let data = NativeEmojiPickerDataLoader.load(arguments: arguments), let presenter = topViewController( diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e1c2ce00f62..5f3b45f36b4 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -403,6 +403,59 @@ class RunnerTests: XCTestCase { } } + func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() { + let order = ["people", "nature", "flags"] + let offsets: [String: CGFloat] = [ + "people": -320, + "nature": -12, + "flags": 200, + ] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0 + ), + "nature" + ) + } + + func testCategoryTrackerFollowsScrollPastEachHeader() { + let order = ["people", "nature", "flags"] + + // Scrolled to the very top: the first section is highlighted. + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: ["people": 0, "nature": 400, "flags": 800], + viewportTop: 0 + ), + "people" + ) + + // Scrolled far enough that Flags has reached the top. + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: ["people": -800, "nature": -400, "flags": 0], + viewportTop: 0 + ), + "flags" + ) + } + + func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() { + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: ["people", "nature"], + offsets: ["people": 40, "nature": 400], + viewportTop: 0 + ), + "people" + ) + } + private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage { let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3)) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart index 9f9ff7cc25f..798089fa01f 100644 --- a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -2,14 +2,48 @@ part of '../emoji_picker.dart'; const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); +/// Guards the process-global native method-call handler: one native sheet may +/// own it at a time. A reentrant open would replace the handler and hijack the +/// live sheet's select/dismiss callbacks, so [_presentIosEmojiPicker] coalesces +/// reentry while a presentation is in flight. +bool _iosEmojiPickerPresenting = false; + +@visibleForTesting +void resetIosEmojiPickerPresentationForTest() { + _iosEmojiPickerPresenting = false; +} + Future _presentIosEmojiPicker({ required BuildContext context, required void Function(String emoji) onSelect, VoidCallback? onDismiss, }) async { + // Only one native sheet owns the handler at a time; coalesce a reentrant open + // so it cannot steal the live sheet's callbacks from its original owner. + if (_iosEmojiPickerPresenting) return; + _iosEmojiPickerPresenting = true; + final container = ProviderScope.containerOf(context, listen: false); - final customEmoji = await container.read(customEmojiPaletteProvider.future); - if (!context.mounted) return; + final List customEmoji; + try { + customEmoji = await container.read(customEmojiPaletteProvider.future); + } catch (_) { + // A palette fetch failure must not strand the composer's open state: fall + // back to the Flutter picker, which watches the palette itself. + _iosEmojiPickerPresenting = false; + if (context.mounted) { + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); + } + return; + } + if (!context.mounted) { + _iosEmojiPickerPresenting = false; + return; + } final recent = container.read(recentEmojiProvider); final mediaAuth = container.read(mediaGetAuthServiceProvider); final prefs = container.read(savedPrefsProvider); @@ -19,6 +53,7 @@ Future _presentIosEmojiPicker({ void finish() { if (dismissed) return; dismissed = true; + _iosEmojiPickerPresenting = false; _nativeEmojiPickerChannel.setMethodCallHandler(null); onDismiss?.call(); } @@ -72,8 +107,14 @@ Future _presentIosEmojiPicker({ // A native presentation failure should not remove the emoji affordance. } - if (dismissed || !context.mounted) return; + if (dismissed || !context.mounted) { + // `dismissed` means finish() already released the guard; the unmounted + // path releases it here so a future open is not blocked. + _iosEmojiPickerPresenting = false; + return; + } dismissed = true; + _iosEmojiPickerPresenting = false; _nativeEmojiPickerChannel.setMethodCallHandler(null); _showFlutterEmojiPicker( context: context, diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index e2e29224c0f..8c7c05ef577 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -621,6 +621,8 @@ void main() { }); group('iOS native emoji picker', () { + setUp(resetIosEmojiPickerPresentationForTest); + testWidgets('passes shared theme and custom emoji into the native sheet', ( tester, ) async { @@ -811,6 +813,152 @@ void main() { debugDefaultTargetPlatformOverride = previousPlatform; } }); + + testWidgets( + 'a palette load failure falls back to the Flutter picker exactly once', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + var presents = 0; + _setMockNativeEmojiPickerHandler((_) async { + presents += 1; + return true; + }); + var dismissals = 0; + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier( + Future.error(StateError('palette unavailable')), + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: (_) {}, + onDismiss: () => dismissals += 1, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + + // The native sheet is never presented on a palette error; the + // Flutter picker takes over so the composer's open state is not + // stranded. + expect(presents, 0); + expect(find.byType(EmojiPickerSheet), findsOneWidget); + + // Dismissing the fallback runs onDismiss exactly once. + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + expect(dismissals, 1); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets('a reentrant open cannot steal the live sheet callbacks', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + var presents = 0; + _setMockNativeEmojiPickerHandler((call) async { + if (call.method == 'present') presents += 1; + return true; + }); + + final firstSelected = []; + final secondSelected = []; + var firstDismissals = 0; + var secondDismissals = 0; + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + customEmojiPaletteProvider.overrideWith( + () => + _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: firstSelected.add, + onDismiss: () => firstDismissals += 1, + ), + child: const Text('Open first'), + ), + FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: secondSelected.add, + onDismiss: () => secondDismissals += 1, + ), + child: const Text('Open second'), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open first')); + await tester.pumpAndSettle(); + expect(presents, 1); + + // A second open while the first sheet is live is coalesced: it neither + // presents again nor replaces the live sheet's method-call handler. + await tester.tap(find.text('Open second')); + await tester.pumpAndSettle(); + expect(presents, 1); + + // Native events still reach the original owner, and only it. + await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); + await _sendNativeEmojiPickerCall(tester, 'dismissed'); + expect(firstSelected, ['\u{1F525}']); + expect(secondSelected, isEmpty); + expect(firstDismissals, 1); + expect(secondDismissals, 0); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); }); group('recent emoji ranking', () { From 538a5ed0443a407b3a63bc56fb1ffd5fed506540 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Mon, 17 Aug 2026 10:41:34 +0100 Subject: [PATCH 06/12] Split native emoji picker below the 1000-line ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Codex file-size finding: NativeEmojiPicker.swift was 1043 lines, over the 1000-line hard ceiling documented in AGENTS.md. Splits the single file into three focused siblings with no behavior change: - NativeEmojiPickerModel.swift — data models, JSON parsing, search scoring, section-offset preference key, and the pure NativeEmojiCategoryTracker. - NativeEmojiPickerView.swift — the SwiftUI NativeEmojiPickerView and NativeEmojiRemoteImage. - NativeEmojiPicker.swift — the coordinator and Flutter method-channel plumbing. Top-level types shared across the new files drop file-scoped 'private' (now internal); every code body is byte-identical to the original. Registers the two new files in the Runner target's build phase. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- mobile/ios/Runner.xcodeproj/project.pbxproj | 8 + mobile/ios/Runner/NativeEmojiPicker.swift | 857 ------------------ .../ios/Runner/NativeEmojiPickerModel.swift | 409 +++++++++ mobile/ios/Runner/NativeEmojiPickerView.swift | 453 +++++++++ 4 files changed, 870 insertions(+), 857 deletions(-) create mode 100644 mobile/ios/Runner/NativeEmojiPickerModel.swift create mode 100644 mobile/ios/Runner/NativeEmojiPickerView.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 37978deaa0e..fc818d9f57b 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; }; 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; }; 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */; }; + 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */; }; + 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -65,6 +67,8 @@ 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = ""; }; + 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = ""; }; + 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -191,6 +195,8 @@ 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */, 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */, 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */, + 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */, + 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -429,6 +435,8 @@ 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */, 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */, 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */, + 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */, + 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift index 21ca0ca1497..b0cd127a96a 100644 --- a/mobile/ios/Runner/NativeEmojiPicker.swift +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -1,864 +1,7 @@ import Flutter -import ImageIO import SwiftUI import UIKit -private struct NativeEmojiPickerAppearance { - let surface: UIColor - let control: UIColor - let text: UIColor - let secondaryText: UIColor - let accent: UIColor - let divider: UIColor - let isDark: Bool - - init(arguments: [String: Any]) { - surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground) - control = Self.color( - arguments["controlColor"], - fallback: .secondarySystemBackground - ) - text = Self.color(arguments["textColor"], fallback: .label) - secondaryText = Self.color( - arguments["secondaryTextColor"], - fallback: .secondaryLabel - ) - accent = Self.color(arguments["accentColor"], fallback: .systemBlue) - divider = Self.color(arguments["dividerColor"], fallback: .separator) - isDark = arguments["isDark"] as? Bool ?? false - } - - private static func color(_ raw: Any?, fallback: UIColor) -> UIColor { - guard let value = (raw as? NSNumber)?.uint32Value else { return fallback } - let alpha = CGFloat((value >> 24) & 0xFF) / 255 - let red = CGFloat((value >> 16) & 0xFF) / 255 - let green = CGFloat((value >> 8) & 0xFF) / 255 - let blue = CGFloat(value & 0xFF) / 255 - return UIColor(red: red, green: green, blue: blue, alpha: alpha) - } -} - -private struct NativeEmojiItem: Identifiable, Hashable { - let id: String - let shortcode: String - let value: String - let name: String - let keywords: [String] - let glyph: String? - let skinVariants: [String] - let imageURL: URL? -} - -private struct NativeEmojiSkinTone: Identifiable { - let id: Int - let label: String - let color: UIColor -} - -private let nativeEmojiSkinTones = [ - NativeEmojiSkinTone( - id: 0, - label: "Default", - color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1) - ), - NativeEmojiSkinTone( - id: 1, - label: "Light", - color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1) - ), - NativeEmojiSkinTone( - id: 2, - label: "Medium-light", - color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1) - ), - NativeEmojiSkinTone( - id: 3, - label: "Medium", - color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1) - ), - NativeEmojiSkinTone( - id: 4, - label: "Medium-dark", - color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1) - ), - NativeEmojiSkinTone( - id: 5, - label: "Dark", - color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1) - ), -] - -private func validNativeEmojiSkinTone(_ value: Int) -> Int { - nativeEmojiSkinTones.indices.contains(value) ? value : 0 -} - -private struct NativeEmojiSection: Identifiable { - let id: String - let title: String - let systemImage: String - let items: [NativeEmojiItem] -} - -private struct NativeEmojiPickerData { - let sections: [NativeEmojiSection] - let standardItems: [NativeEmojiItem] - let customItems: [NativeEmojiItem] -} - -private enum NativeEmojiPickerDataLoader { - static let assetPath = "assets/emoji/emoji-data.json" - - static func load(arguments: [String: Any]) -> NativeEmojiPickerData? { - let key = FlutterDartProject.lookupKey(forAsset: assetPath) - let url = Bundle.main.bundleURL.appendingPathComponent(key) - guard let data = try? Data(contentsOf: url) else { return nil } - return parse(data: data, arguments: arguments) - } - - static func parse( - data: Data, - arguments: [String: Any] - ) -> NativeEmojiPickerData? { - guard - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let rawCategories = root["categories"] as? [[String: Any]], - let rawEmoji = root["emoji"] as? [String: Any] - else { - return nil - } - - var sections: [NativeEmojiSection] = [] - var standardItems: [NativeEmojiItem] = [] - var byValue: [String: NativeEmojiItem] = [:] - - for category in rawCategories { - guard - let categoryID = category["id"] as? String, - let emojiIDs = category["emoji"] as? [String] - else { - continue - } - - var items: [NativeEmojiItem] = [] - for emojiID in emojiIDs { - guard let record = rawEmoji[emojiID] as? [String: Any] else { continue } - let name = record["n"] as? String ?? emojiID - let keywords = record["k"] as? [String] ?? [] - let glyphs: [String] - if let values = record["u"] as? [String] { - glyphs = values - } else if let value = record["u"] as? String { - glyphs = [value] - } else { - glyphs = [] - } - - guard let defaultGlyph = glyphs.first else { continue } - let item = NativeEmojiItem( - id: emojiID, - shortcode: emojiID, - value: defaultGlyph, - name: name, - keywords: keywords, - glyph: defaultGlyph, - skinVariants: glyphs, - imageURL: nil - ) - items.append(item) - standardItems.append(item) - for glyph in glyphs where byValue[glyph] == nil { - byValue[glyph] = item - } - } - - sections.append( - NativeEmojiSection( - id: categoryID, - title: categoryTitle(categoryID), - systemImage: categorySymbol(categoryID), - items: items - ) - ) - } - - let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? [] - let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in - guard - let shortcode = raw["shortcode"] as? String, - let urlString = raw["url"] as? String, - let url = URL(string: urlString) - else { - return nil - } - return NativeEmojiItem( - id: "custom-\(shortcode)", - shortcode: shortcode, - value: ":\(shortcode):", - name: shortcode, - keywords: [], - glyph: nil, - skinVariants: [], - imageURL: url - ) - } - let customByValue = Dictionary( - customItems.map { ($0.value, $0) }, - uniquingKeysWith: { first, _ in first } - ) - - let recentValues = arguments["recent"] as? [String] ?? [] - var seenRecentIDs: Set = [] - let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in - guard let item = byValue[value] ?? customByValue[value] else { return nil } - return seenRecentIDs.insert(item.id).inserted ? item : nil - } - if !recentItems.isEmpty { - sections.insert( - NativeEmojiSection( - id: "frequent", - title: "Frequently used", - systemImage: "clock", - items: recentItems - ), - at: 0 - ) - } - - if !customItems.isEmpty { - sections.append( - NativeEmojiSection( - id: "custom", - title: "Custom", - systemImage: "sparkles", - items: customItems - ) - ) - } - - return NativeEmojiPickerData( - sections: sections, - standardItems: standardItems, - customItems: customItems - ) - } - - private static func categoryTitle(_ id: String) -> String { - switch id { - case "people": return "Smileys & People" - case "nature": return "Animals & Nature" - case "foods": return "Food & Drink" - case "activity": return "Activity" - case "places": return "Travel & Places" - case "objects": return "Objects" - case "symbols": return "Symbols" - case "flags": return "Flags" - default: return id.capitalized - } - } - - private static func categorySymbol(_ id: String) -> String { - switch id { - case "people": return "face.smiling" - case "nature": return "leaf" - case "foods": return "fork.knife" - case "activity": return "figure.run" - case "places": return "airplane" - case "objects": return "lightbulb" - case "symbols": return "heart" - case "flags": return "flag" - default: return "circle.grid.3x3" - } - } -} - -private struct NativeEmojiSearchScore: Comparable { - let tier: Int - let detail: Int - let length: Int - let code: String - - static func < (lhs: Self, rhs: Self) -> Bool { - if lhs.tier != rhs.tier { return lhs.tier < rhs.tier } - if lhs.detail != rhs.detail { return lhs.detail < rhs.detail } - if lhs.length != rhs.length { return lhs.length < rhs.length } - return lhs.code < rhs.code - } -} - -private enum NativeEmojiSearch { - static func results( - query: String, - items: [NativeEmojiItem] - ) -> [NativeEmojiItem] { - items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in - guard let score = score(query: query, item: item) else { return nil } - return (item, score) - } - .sorted { $0.1 < $1.1 } - .map(\.0) - } - - private static func score( - query: String, - item: NativeEmojiItem - ) -> NativeEmojiSearchScore? { - let normalizedQuery = collapse(query) - guard !normalizedQuery.isEmpty else { return nil } - let code = item.shortcode.lowercased() - let normalizedCode = collapse(code) - - if normalizedCode == normalizedQuery { - return makeScore(tier: 0, detail: 0, code: code) - } - if normalizedCode.hasPrefix(normalizedQuery) { - return makeScore(tier: 1, detail: 0, code: code) - } - - let words = ([item.name] + item.keywords) - .flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) } - .map(String.init) - if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) { - return makeScore(tier: 2, detail: index, code: code) - } - if let range = normalizedCode.range(of: normalizedQuery) { - return makeScore( - tier: 3, - detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound), - code: code - ) - } - if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) { - return makeScore(tier: 4, detail: index, code: code) - } - if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) { - return makeScore(tier: 5, detail: span, code: code) - } - return nil - } - - private static func makeScore( - tier: Int, - detail: Int, - code: String - ) -> NativeEmojiSearchScore { - NativeEmojiSearchScore( - tier: tier, - detail: detail, - length: code.count, - code: code - ) - } - - private static func collapse(_ value: String) -> String { - value.lowercased().filter { !":_ -\t\n".contains($0) } - } - - private static func subsequenceSpan(_ query: String, in target: String) -> Int? { - let queryCharacters = Array(query) - guard !queryCharacters.isEmpty else { return nil } - var queryIndex = 0 - var first: Int? - var last = 0 - for (targetIndex, character) in target.enumerated() { - guard character == queryCharacters[queryIndex] else { continue } - if first == nil { first = targetIndex } - last = targetIndex - queryIndex += 1 - if queryIndex == queryCharacters.count { - return last - (first ?? last) - } - } - return nil - } -} - -/// The top offset of each pinned section header, keyed by section id, reported -/// up from the scrolling grid so the rail can follow manual scrolling. -private struct NativeEmojiSectionOffsetsKey: PreferenceKey { - static let defaultValue: [String: CGFloat] = [:] - - static func reduce( - value: inout [String: CGFloat], - nextValue: () -> [String: CGFloat] - ) { - value.merge(nextValue(), uniquingKeysWith: { _, next in next }) - } -} - -/// Pure selection logic: the highlighted section is the last one whose header -/// has scrolled to or above the top of the viewport. Extracted so the -/// scroll-tracking behaviour can be unit-tested without a live scroll view. -enum NativeEmojiCategoryTracker { - static func selectedSectionID( - order: [String], - offsets: [String: CGFloat], - viewportTop: CGFloat - ) -> String? { - var selected: String? - for id in order { - guard let top = offsets[id] else { continue } - // A small tolerance keeps the header that is flush with the top pinned as - // selected rather than flickering to the next section. - if top <= viewportTop + 1 { - selected = id - } else { - break - } - } - return selected ?? order.first - } -} - -private struct NativeEmojiPickerView: View { - let data: NativeEmojiPickerData - let appearance: NativeEmojiPickerAppearance - let onSelect: (String) -> Void - let onSkinToneChanged: (Int) -> Void - let onClose: () -> Void - - @State private var query = "" - @State private var selectedSectionID: String? - @State private var selectedSkinTone: Int - - private let columns = Array( - repeating: GridItem(.flexible(minimum: 36), spacing: 0), - count: 8 - ) - - private let sectionListSpace = "buzz.emoji.sectionList" - - init( - data: NativeEmojiPickerData, - appearance: NativeEmojiPickerAppearance, - initialSkinTone: Int, - onSelect: @escaping (String) -> Void, - onSkinToneChanged: @escaping (Int) -> Void, - onClose: @escaping () -> Void - ) { - self.data = data - self.appearance = appearance - self.onSelect = onSelect - self.onSkinToneChanged = onSkinToneChanged - self.onClose = onClose - _selectedSkinTone = State( - initialValue: validNativeEmojiSkinTone(initialSkinTone) - ) - } - - var body: some View { - ScrollViewReader { proxy in - VStack(spacing: 0) { - header - if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - categoryRail(proxy) - } - Divider().overlay(Color(uiColor: appearance.divider)) - pickerContent - } - .background(Color(uiColor: appearance.surface)) - .onAppear { - selectedSectionID = data.sections.first?.id - } - } - } - - private var header: some View { - HStack(spacing: 8) { - HStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(Color(uiColor: appearance.secondaryText)) - TextField("Search emoji", text: $query) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .submitLabel(.search) - .foregroundStyle(Color(uiColor: appearance.text)) - if !query.isEmpty { - Button { - query = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color(uiColor: appearance.secondaryText)) - } - .buttonStyle(.plain) - .accessibilityLabel("Clear search") - } - } - .padding(.horizontal, 14) - .frame(height: 44) - .background(Color(uiColor: appearance.control), in: Capsule()) - .overlay { - Capsule() - .stroke(Color(uiColor: appearance.divider), lineWidth: 1) - } - - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color(uiColor: appearance.text)) - .frame(width: 44, height: 44) - .background(Color(uiColor: appearance.control), in: Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Close sheet") - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 8) - } - - private func categoryRail(_ proxy: ScrollViewProxy) -> some View { - HStack(spacing: 0) { - ForEach(data.sections) { section in - Button { - selectedSectionID = section.id - withAnimation(.easeOut(duration: 0.24)) { - proxy.scrollTo("section-\(section.id)", anchor: .top) - } - } label: { - Image(systemName: section.systemImage) - .font(.system(size: 18, weight: .medium)) - .foregroundStyle( - Color( - uiColor: selectedSectionID == section.id - ? appearance.accent : appearance.secondaryText - ) - ) - .frame(maxWidth: .infinity) - .frame(height: 36) - .background( - selectedSectionID == section.id - ? Color(uiColor: appearance.control) : Color.clear, - in: Circle() - ) - } - .frame(maxWidth: .infinity) - .buttonStyle(.plain) - .accessibilityLabel(section.title) - .accessibilityAddTraits( - selectedSectionID == section.id ? .isSelected : [] - ) - } - Divider() - .frame(height: 24) - .overlay(Color(uiColor: appearance.divider)) - skinToneSelector - .frame(maxWidth: .infinity) - } - .padding(.horizontal, 16) - .frame(height: 44) - } - - private var skinToneSelector: some View { - Menu { - ForEach(nativeEmojiSkinTones) { tone in - Button { - selectedSkinTone = tone.id - onSkinToneChanged(tone.id) - } label: { - Label { - Text(tone.label) - } icon: { - Image(uiImage: skinTonePreviewImage(tone)) - .renderingMode(.original) - } - } - } - } label: { - skinToneDot(nativeEmojiSkinTones[selectedSkinTone]) - .frame(maxWidth: .infinity) - .frame(height: 36) - } - .buttonStyle(.plain) - .accessibilityLabel("Skin tone") - } - - private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View { - Circle() - .fill(Color(uiColor: tone.color)) - .frame(width: 16, height: 16) - .overlay { - Circle() - .fill( - LinearGradient( - colors: [.white.opacity(0.2), .clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .blendMode(.overlay) - } - .overlay { - Circle().stroke(.black.opacity(0.8), lineWidth: 1) - } - } - - private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage { - let size = CGSize(width: 16, height: 16) - return UIGraphicsImageRenderer(size: size).image { rendererContext in - let context = rendererContext.cgContext - let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5) - let circle = UIBezierPath(ovalIn: rect) - - tone.color.setFill() - circle.fill() - - if let gradient = CGGradient( - colorsSpace: CGColorSpaceCreateDeviceRGB(), - colors: [ - UIColor.white.withAlphaComponent(0.2).cgColor, - UIColor.clear.cgColor, - ] as CFArray, - locations: [0, 1] - ) { - context.saveGState() - circle.addClip() - context.setBlendMode(.overlay) - context.drawLinearGradient( - gradient, - start: CGPoint(x: size.width / 2, y: 0), - end: CGPoint(x: size.width / 2, y: size.height), - options: [] - ) - context.restoreGState() - } - - UIColor.black.withAlphaComponent(0.8).setStroke() - circle.lineWidth = 1 - circle.stroke() - } - } - - @ViewBuilder - private var pickerContent: some View { - let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedQuery.isEmpty { - sectionList(data.sections, tracksSelection: true) - } else { - let custom = NativeEmojiSearch.results( - query: trimmedQuery, - items: data.customItems - ) - let standard = NativeEmojiSearch.results( - query: trimmedQuery, - items: data.standardItems - ) - let sections = [ - NativeEmojiSection( - id: "search-custom", - title: "Custom", - systemImage: "sparkles", - items: custom - ), - NativeEmojiSection( - id: "search-standard", - title: "Emoji", - systemImage: "face.smiling", - items: standard - ), - ].filter { !$0.items.isEmpty } - - if sections.isEmpty { - VStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .font(.system(size: 28)) - Text("No emoji found").font(.body) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .foregroundStyle(Color(uiColor: appearance.secondaryText)) - } else { - sectionList(sections, tracksSelection: false) - } - } - } - - private func sectionList( - _ sections: [NativeEmojiSection], - tracksSelection: Bool - ) -> some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(sections) { section in - Section { - LazyVGrid(columns: columns, spacing: 0) { - ForEach(section.items) { item in - emojiButton(item) - } - } - .padding(.horizontal, 16) - } header: { - HStack { - Text(section.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(Color(uiColor: appearance.secondaryText)) - Spacer() - } - .padding(.horizontal, 16) - .frame(height: 30) - .background(Color(uiColor: appearance.surface)) - .background(sectionOffsetReporter(id: section.id)) - .id("section-\(section.id)") - } - } - } - .padding(.bottom, 8) - } - .coordinateSpace(name: sectionListSpace) - .scrollDismissesKeyboard(.interactively) - .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in - guard tracksSelection else { return } - selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID( - order: data.sections.map(\.id), - offsets: offsets, - viewportTop: 0 - ) - } - } - - private func sectionOffsetReporter(id: String) -> some View { - GeometryReader { geometry in - Color.clear.preference( - key: NativeEmojiSectionOffsetsKey.self, - value: [id: geometry.frame(in: .named(sectionListSpace)).minY] - ) - } - } - - private func emojiButton(_ item: NativeEmojiItem) -> some View { - let value = displayValue(for: item) - return Button { - onSelect(value) - } label: { - Group { - if let url = item.imageURL { - NativeEmojiRemoteImage( - url: url, - fallbackColor: appearance.secondaryText - ) - .frame(width: 28, height: 28) - } else { - Text(value).font(.system(size: 28)) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(item.name) - } - - private func displayValue(for item: NativeEmojiItem) -> String { - guard item.imageURL == nil else { return item.value } - guard item.skinVariants.indices.contains(selectedSkinTone) else { - return item.skinVariants.first ?? item.value - } - return item.skinVariants[selectedSkinTone] - } -} - -private struct NativeEmojiRemoteImage: View { - let url: URL - let fallbackColor: UIColor - - @State private var phase: Phase = .loading - private static let maxDownloadBytes = 10 * 1024 * 1024 - private static let maxThumbnailPixels = 84 - - private enum Phase { - case loading - case success(UIImage) - case failure - } - - var body: some View { - Group { - switch phase { - case .loading: - ProgressView().controlSize(.mini) - case .success(let image): - Image(uiImage: image).resizable().scaledToFit() - case .failure: - Image(systemName: "sparkles") - .foregroundStyle(Color(uiColor: fallbackColor)) - } - } - .task(id: requestIdentity) { - do { - let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders( - for: url - ) - var request = URLRequest(url: url) - for (name, value) in requestHeaders { - request.setValue(value, forHTTPHeaderField: name) - } - let (bytes, response) = try await URLSession.shared.bytes(for: request) - guard - let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) - else { - phase = .failure - return - } - if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), - let byteCount = Int(contentLength), - byteCount > Self.maxDownloadBytes - { - phase = .failure - return - } - var data = Data() - let expected = httpResponse.expectedContentLength - if expected > 0 { - data.reserveCapacity( - Int(min(expected, Int64(Self.maxDownloadBytes))) - ) - } - for try await byte in bytes { - guard data.count < Self.maxDownloadBytes else { - phase = .failure - return - } - data.append(byte) - } - guard let image = Self.thumbnail(from: data) else { - phase = .failure - return - } - phase = .success(image) - } catch { - if !Task.isCancelled { phase = .failure } - } - } - } - - private static func thumbnail(from data: Data) -> UIImage? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return nil - } - let options: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels, - kCGImageSourceShouldCacheImmediately: true, - ] - guard - let image = CGImageSourceCreateThumbnailAtIndex( - source, - 0, - options as CFDictionary - ) - else { - return nil - } - return UIImage(cgImage: image) - } - - private var requestIdentity: String { - url.absoluteString - } -} - private struct NativeEmojiMediaHeaderError: Error {} final class NativeEmojiPickerCoordinator: NSObject, diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift new file mode 100644 index 00000000000..bf7e920fcd4 --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift @@ -0,0 +1,409 @@ +import Flutter +import SwiftUI +import UIKit + +struct NativeEmojiPickerAppearance { + let surface: UIColor + let control: UIColor + let text: UIColor + let secondaryText: UIColor + let accent: UIColor + let divider: UIColor + let isDark: Bool + + init(arguments: [String: Any]) { + surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground) + control = Self.color( + arguments["controlColor"], + fallback: .secondarySystemBackground + ) + text = Self.color(arguments["textColor"], fallback: .label) + secondaryText = Self.color( + arguments["secondaryTextColor"], + fallback: .secondaryLabel + ) + accent = Self.color(arguments["accentColor"], fallback: .systemBlue) + divider = Self.color(arguments["dividerColor"], fallback: .separator) + isDark = arguments["isDark"] as? Bool ?? false + } + + private static func color(_ raw: Any?, fallback: UIColor) -> UIColor { + guard let value = (raw as? NSNumber)?.uint32Value else { return fallback } + let alpha = CGFloat((value >> 24) & 0xFF) / 255 + let red = CGFloat((value >> 16) & 0xFF) / 255 + let green = CGFloat((value >> 8) & 0xFF) / 255 + let blue = CGFloat(value & 0xFF) / 255 + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +struct NativeEmojiItem: Identifiable, Hashable { + let id: String + let shortcode: String + let value: String + let name: String + let keywords: [String] + let glyph: String? + let skinVariants: [String] + let imageURL: URL? +} + +struct NativeEmojiSkinTone: Identifiable { + let id: Int + let label: String + let color: UIColor +} + +let nativeEmojiSkinTones = [ + NativeEmojiSkinTone( + id: 0, + label: "Default", + color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1) + ), + NativeEmojiSkinTone( + id: 1, + label: "Light", + color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1) + ), + NativeEmojiSkinTone( + id: 2, + label: "Medium-light", + color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1) + ), + NativeEmojiSkinTone( + id: 3, + label: "Medium", + color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1) + ), + NativeEmojiSkinTone( + id: 4, + label: "Medium-dark", + color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1) + ), + NativeEmojiSkinTone( + id: 5, + label: "Dark", + color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1) + ), +] + +func validNativeEmojiSkinTone(_ value: Int) -> Int { + nativeEmojiSkinTones.indices.contains(value) ? value : 0 +} + +struct NativeEmojiSection: Identifiable { + let id: String + let title: String + let systemImage: String + let items: [NativeEmojiItem] +} + +struct NativeEmojiPickerData { + let sections: [NativeEmojiSection] + let standardItems: [NativeEmojiItem] + let customItems: [NativeEmojiItem] +} + +enum NativeEmojiPickerDataLoader { + static let assetPath = "assets/emoji/emoji-data.json" + + static func load(arguments: [String: Any]) -> NativeEmojiPickerData? { + let key = FlutterDartProject.lookupKey(forAsset: assetPath) + let url = Bundle.main.bundleURL.appendingPathComponent(key) + guard let data = try? Data(contentsOf: url) else { return nil } + return parse(data: data, arguments: arguments) + } + + static func parse( + data: Data, + arguments: [String: Any] + ) -> NativeEmojiPickerData? { + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawCategories = root["categories"] as? [[String: Any]], + let rawEmoji = root["emoji"] as? [String: Any] + else { + return nil + } + + var sections: [NativeEmojiSection] = [] + var standardItems: [NativeEmojiItem] = [] + var byValue: [String: NativeEmojiItem] = [:] + + for category in rawCategories { + guard + let categoryID = category["id"] as? String, + let emojiIDs = category["emoji"] as? [String] + else { + continue + } + + var items: [NativeEmojiItem] = [] + for emojiID in emojiIDs { + guard let record = rawEmoji[emojiID] as? [String: Any] else { continue } + let name = record["n"] as? String ?? emojiID + let keywords = record["k"] as? [String] ?? [] + let glyphs: [String] + if let values = record["u"] as? [String] { + glyphs = values + } else if let value = record["u"] as? String { + glyphs = [value] + } else { + glyphs = [] + } + + guard let defaultGlyph = glyphs.first else { continue } + let item = NativeEmojiItem( + id: emojiID, + shortcode: emojiID, + value: defaultGlyph, + name: name, + keywords: keywords, + glyph: defaultGlyph, + skinVariants: glyphs, + imageURL: nil + ) + items.append(item) + standardItems.append(item) + for glyph in glyphs where byValue[glyph] == nil { + byValue[glyph] = item + } + } + + sections.append( + NativeEmojiSection( + id: categoryID, + title: categoryTitle(categoryID), + systemImage: categorySymbol(categoryID), + items: items + ) + ) + } + + let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? [] + let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in + guard + let shortcode = raw["shortcode"] as? String, + let urlString = raw["url"] as? String, + let url = URL(string: urlString) + else { + return nil + } + return NativeEmojiItem( + id: "custom-\(shortcode)", + shortcode: shortcode, + value: ":\(shortcode):", + name: shortcode, + keywords: [], + glyph: nil, + skinVariants: [], + imageURL: url + ) + } + let customByValue = Dictionary( + customItems.map { ($0.value, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let recentValues = arguments["recent"] as? [String] ?? [] + var seenRecentIDs: Set = [] + let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in + guard let item = byValue[value] ?? customByValue[value] else { return nil } + return seenRecentIDs.insert(item.id).inserted ? item : nil + } + if !recentItems.isEmpty { + sections.insert( + NativeEmojiSection( + id: "frequent", + title: "Frequently used", + systemImage: "clock", + items: recentItems + ), + at: 0 + ) + } + + if !customItems.isEmpty { + sections.append( + NativeEmojiSection( + id: "custom", + title: "Custom", + systemImage: "sparkles", + items: customItems + ) + ) + } + + return NativeEmojiPickerData( + sections: sections, + standardItems: standardItems, + customItems: customItems + ) + } + + private static func categoryTitle(_ id: String) -> String { + switch id { + case "people": return "Smileys & People" + case "nature": return "Animals & Nature" + case "foods": return "Food & Drink" + case "activity": return "Activity" + case "places": return "Travel & Places" + case "objects": return "Objects" + case "symbols": return "Symbols" + case "flags": return "Flags" + default: return id.capitalized + } + } + + private static func categorySymbol(_ id: String) -> String { + switch id { + case "people": return "face.smiling" + case "nature": return "leaf" + case "foods": return "fork.knife" + case "activity": return "figure.run" + case "places": return "airplane" + case "objects": return "lightbulb" + case "symbols": return "heart" + case "flags": return "flag" + default: return "circle.grid.3x3" + } + } +} + +private struct NativeEmojiSearchScore: Comparable { + let tier: Int + let detail: Int + let length: Int + let code: String + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.tier != rhs.tier { return lhs.tier < rhs.tier } + if lhs.detail != rhs.detail { return lhs.detail < rhs.detail } + if lhs.length != rhs.length { return lhs.length < rhs.length } + return lhs.code < rhs.code + } +} + +enum NativeEmojiSearch { + static func results( + query: String, + items: [NativeEmojiItem] + ) -> [NativeEmojiItem] { + items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in + guard let score = score(query: query, item: item) else { return nil } + return (item, score) + } + .sorted { $0.1 < $1.1 } + .map(\.0) + } + + private static func score( + query: String, + item: NativeEmojiItem + ) -> NativeEmojiSearchScore? { + let normalizedQuery = collapse(query) + guard !normalizedQuery.isEmpty else { return nil } + let code = item.shortcode.lowercased() + let normalizedCode = collapse(code) + + if normalizedCode == normalizedQuery { + return makeScore(tier: 0, detail: 0, code: code) + } + if normalizedCode.hasPrefix(normalizedQuery) { + return makeScore(tier: 1, detail: 0, code: code) + } + + let words = ([item.name] + item.keywords) + .flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) } + .map(String.init) + if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) { + return makeScore(tier: 2, detail: index, code: code) + } + if let range = normalizedCode.range(of: normalizedQuery) { + return makeScore( + tier: 3, + detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound), + code: code + ) + } + if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) { + return makeScore(tier: 4, detail: index, code: code) + } + if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) { + return makeScore(tier: 5, detail: span, code: code) + } + return nil + } + + private static func makeScore( + tier: Int, + detail: Int, + code: String + ) -> NativeEmojiSearchScore { + NativeEmojiSearchScore( + tier: tier, + detail: detail, + length: code.count, + code: code + ) + } + + private static func collapse(_ value: String) -> String { + value.lowercased().filter { !":_ -\t\n".contains($0) } + } + + private static func subsequenceSpan(_ query: String, in target: String) -> Int? { + let queryCharacters = Array(query) + guard !queryCharacters.isEmpty else { return nil } + var queryIndex = 0 + var first: Int? + var last = 0 + for (targetIndex, character) in target.enumerated() { + guard character == queryCharacters[queryIndex] else { continue } + if first == nil { first = targetIndex } + last = targetIndex + queryIndex += 1 + if queryIndex == queryCharacters.count { + return last - (first ?? last) + } + } + return nil + } +} + +/// The top offset of each pinned section header, keyed by section id, reported +/// up from the scrolling grid so the rail can follow manual scrolling. +struct NativeEmojiSectionOffsetsKey: PreferenceKey { + static let defaultValue: [String: CGFloat] = [:] + + static func reduce( + value: inout [String: CGFloat], + nextValue: () -> [String: CGFloat] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + +/// Pure selection logic: the highlighted section is the last one whose header +/// has scrolled to or above the top of the viewport. Extracted so the +/// scroll-tracking behaviour can be unit-tested without a live scroll view. +enum NativeEmojiCategoryTracker { + static func selectedSectionID( + order: [String], + offsets: [String: CGFloat], + viewportTop: CGFloat + ) -> String? { + var selected: String? + for id in order { + guard let top = offsets[id] else { continue } + // A small tolerance keeps the header that is flush with the top pinned as + // selected rather than flickering to the next section. + if top <= viewportTop + 1 { + selected = id + } else { + break + } + } + return selected ?? order.first + } +} diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift new file mode 100644 index 00000000000..45713940bc9 --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -0,0 +1,453 @@ +import ImageIO +import SwiftUI +import UIKit + +struct NativeEmojiPickerView: View { + let data: NativeEmojiPickerData + let appearance: NativeEmojiPickerAppearance + let onSelect: (String) -> Void + let onSkinToneChanged: (Int) -> Void + let onClose: () -> Void + + @State private var query = "" + @State private var selectedSectionID: String? + @State private var selectedSkinTone: Int + + private let columns = Array( + repeating: GridItem(.flexible(minimum: 36), spacing: 0), + count: 8 + ) + + private let sectionListSpace = "buzz.emoji.sectionList" + + init( + data: NativeEmojiPickerData, + appearance: NativeEmojiPickerAppearance, + initialSkinTone: Int, + onSelect: @escaping (String) -> Void, + onSkinToneChanged: @escaping (Int) -> Void, + onClose: @escaping () -> Void + ) { + self.data = data + self.appearance = appearance + self.onSelect = onSelect + self.onSkinToneChanged = onSkinToneChanged + self.onClose = onClose + _selectedSkinTone = State( + initialValue: validNativeEmojiSkinTone(initialSkinTone) + ) + } + + var body: some View { + ScrollViewReader { proxy in + VStack(spacing: 0) { + header + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + categoryRail(proxy) + } + Divider().overlay(Color(uiColor: appearance.divider)) + pickerContent + } + .background(Color(uiColor: appearance.surface)) + .onAppear { + selectedSectionID = data.sections.first?.id + } + } + } + + private var header: some View { + HStack(spacing: 8) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + TextField("Search emoji", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.search) + .foregroundStyle(Color(uiColor: appearance.text)) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(Color(uiColor: appearance.control), in: Capsule()) + .overlay { + Capsule() + .stroke(Color(uiColor: appearance.divider), lineWidth: 1) + } + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(uiColor: appearance.text)) + .frame(width: 44, height: 44) + .background(Color(uiColor: appearance.control), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close sheet") + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } + + private func categoryRail(_ proxy: ScrollViewProxy) -> some View { + HStack(spacing: 0) { + ForEach(data.sections) { section in + Button { + selectedSectionID = section.id + withAnimation(.easeOut(duration: 0.24)) { + proxy.scrollTo("section-\(section.id)", anchor: .top) + } + } label: { + Image(systemName: section.systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle( + Color( + uiColor: selectedSectionID == section.id + ? appearance.accent : appearance.secondaryText + ) + ) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background( + selectedSectionID == section.id + ? Color(uiColor: appearance.control) : Color.clear, + in: Circle() + ) + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .accessibilityLabel(section.title) + .accessibilityAddTraits( + selectedSectionID == section.id ? .isSelected : [] + ) + } + Divider() + .frame(height: 24) + .overlay(Color(uiColor: appearance.divider)) + skinToneSelector + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 16) + .frame(height: 44) + } + + private var skinToneSelector: some View { + Menu { + ForEach(nativeEmojiSkinTones) { tone in + Button { + selectedSkinTone = tone.id + onSkinToneChanged(tone.id) + } label: { + Label { + Text(tone.label) + } icon: { + Image(uiImage: skinTonePreviewImage(tone)) + .renderingMode(.original) + } + } + } + } label: { + skinToneDot(nativeEmojiSkinTones[selectedSkinTone]) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.plain) + .accessibilityLabel("Skin tone") + } + + private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View { + Circle() + .fill(Color(uiColor: tone.color)) + .frame(width: 16, height: 16) + .overlay { + Circle() + .fill( + LinearGradient( + colors: [.white.opacity(0.2), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .blendMode(.overlay) + } + .overlay { + Circle().stroke(.black.opacity(0.8), lineWidth: 1) + } + } + + private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage { + let size = CGSize(width: 16, height: 16) + return UIGraphicsImageRenderer(size: size).image { rendererContext in + let context = rendererContext.cgContext + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5) + let circle = UIBezierPath(ovalIn: rect) + + tone.color.setFill() + circle.fill() + + if let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [ + UIColor.white.withAlphaComponent(0.2).cgColor, + UIColor.clear.cgColor, + ] as CFArray, + locations: [0, 1] + ) { + context.saveGState() + circle.addClip() + context.setBlendMode(.overlay) + context.drawLinearGradient( + gradient, + start: CGPoint(x: size.width / 2, y: 0), + end: CGPoint(x: size.width / 2, y: size.height), + options: [] + ) + context.restoreGState() + } + + UIColor.black.withAlphaComponent(0.8).setStroke() + circle.lineWidth = 1 + circle.stroke() + } + } + + @ViewBuilder + private var pickerContent: some View { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.isEmpty { + sectionList(data.sections, tracksSelection: true) + } else { + let custom = NativeEmojiSearch.results( + query: trimmedQuery, + items: data.customItems + ) + let standard = NativeEmojiSearch.results( + query: trimmedQuery, + items: data.standardItems + ) + let sections = [ + NativeEmojiSection( + id: "search-custom", + title: "Custom", + systemImage: "sparkles", + items: custom + ), + NativeEmojiSection( + id: "search-standard", + title: "Emoji", + systemImage: "face.smiling", + items: standard + ), + ].filter { !$0.items.isEmpty } + + if sections.isEmpty { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 28)) + Text("No emoji found").font(.body) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + } else { + sectionList(sections, tracksSelection: false) + } + } + } + + private func sectionList( + _ sections: [NativeEmojiSection], + tracksSelection: Bool + ) -> some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(sections) { section in + Section { + LazyVGrid(columns: columns, spacing: 0) { + ForEach(section.items) { item in + emojiButton(item) + } + } + .padding(.horizontal, 16) + } header: { + HStack { + Text(section.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 30) + .background(Color(uiColor: appearance.surface)) + .background(sectionOffsetReporter(id: section.id)) + .id("section-\(section.id)") + } + } + } + .padding(.bottom, 8) + } + .coordinateSpace(name: sectionListSpace) + .scrollDismissesKeyboard(.interactively) + .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in + guard tracksSelection else { return } + selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID( + order: data.sections.map(\.id), + offsets: offsets, + viewportTop: 0 + ) + } + } + + private func sectionOffsetReporter(id: String) -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [id: geometry.frame(in: .named(sectionListSpace)).minY] + ) + } + } + + private func emojiButton(_ item: NativeEmojiItem) -> some View { + let value = displayValue(for: item) + return Button { + onSelect(value) + } label: { + Group { + if let url = item.imageURL { + NativeEmojiRemoteImage( + url: url, + fallbackColor: appearance.secondaryText + ) + .frame(width: 28, height: 28) + } else { + Text(value).font(.system(size: 28)) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.name) + } + + private func displayValue(for item: NativeEmojiItem) -> String { + guard item.imageURL == nil else { return item.value } + guard item.skinVariants.indices.contains(selectedSkinTone) else { + return item.skinVariants.first ?? item.value + } + return item.skinVariants[selectedSkinTone] + } +} + +struct NativeEmojiRemoteImage: View { + let url: URL + let fallbackColor: UIColor + + @State private var phase: Phase = .loading + private static let maxDownloadBytes = 10 * 1024 * 1024 + private static let maxThumbnailPixels = 84 + + private enum Phase { + case loading + case success(UIImage) + case failure + } + + var body: some View { + Group { + switch phase { + case .loading: + ProgressView().controlSize(.mini) + case .success(let image): + Image(uiImage: image).resizable().scaledToFit() + case .failure: + Image(systemName: "sparkles") + .foregroundStyle(Color(uiColor: fallbackColor)) + } + } + .task(id: requestIdentity) { + do { + let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders( + for: url + ) + var request = URLRequest(url: url) + for (name, value) in requestHeaders { + request.setValue(value, forHTTPHeaderField: name) + } + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + phase = .failure + return + } + if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let byteCount = Int(contentLength), + byteCount > Self.maxDownloadBytes + { + phase = .failure + return + } + var data = Data() + let expected = httpResponse.expectedContentLength + if expected > 0 { + data.reserveCapacity( + Int(min(expected, Int64(Self.maxDownloadBytes))) + ) + } + for try await byte in bytes { + guard data.count < Self.maxDownloadBytes else { + phase = .failure + return + } + data.append(byte) + } + guard let image = Self.thumbnail(from: data) else { + phase = .failure + return + } + phase = .success(image) + } catch { + if !Task.isCancelled { phase = .failure } + } + } + } + + private static func thumbnail(from data: Data) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels, + kCGImageSourceShouldCacheImmediately: true, + ] + guard + let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) + else { + return nil + } + return UIImage(cgImage: image) + } + + private var requestIdentity: String { + url.absoluteString + } +} From 7940c6ff7ed60306f7a1fcf2eca03f900091b723 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Mon, 17 Aug 2026 14:24:06 +0100 Subject: [PATCH 07/12] Ignore duplicate emoji selections during sheet dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native picker sheet stays live through its dismissal animation, so a second emoji tap before dismissal completes fired the Flutter selected handler again — inserting two emoji or issuing multiple reactions from a picker meant to return a single selection. Mark the coordinator as dismissing on the first selection and ignore further taps until a fresh present() resets the flag. No UI or interaction change; only the duplicate terminal callback is suppressed. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeEmojiPicker.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift index b0cd127a96a..3109923daba 100644 --- a/mobile/ios/Runner/NativeEmojiPicker.swift +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -12,6 +12,7 @@ final class NativeEmojiPickerCoordinator: NSObject, private weak var parentViewController: UIViewController? private weak var presentedController: UIViewController? private var didNotifyDismissal = false + private var isDismissing = false init( messenger: FlutterBinaryMessenger, @@ -81,6 +82,7 @@ final class NativeEmojiPickerCoordinator: NSObject, } didNotifyDismissal = false + isDismissing = false let appearance = NativeEmojiPickerAppearance(arguments: arguments) let content = NativeEmojiPickerView( data: data, @@ -129,12 +131,17 @@ final class NativeEmojiPickerCoordinator: NSObject, @MainActor private func select(_ emoji: String) { + // A single presentation returns at most one selection. The sheet stays + // live through its dismissal animation, so ignore extra taps that arrive + // before dismissal completes to avoid emitting duplicate selections. + guard !isDismissing else { return } channel.invokeMethod("selected", arguments: emoji) dismiss() } @MainActor private func dismiss() { + isDismissing = true guard let controller = presentedController else { notifyDismissalIfNeeded() return From abeb2338e6b0c5ced4f140e11f0afe95598198a5 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Mon, 17 Aug 2026 15:54:00 +0100 Subject: [PATCH 08/12] Bound concurrent native emoji downloads Route custom emoji thumbnails through a shared actor that limits active network transfers to four and keeps decoded thumbnails in an 8 MiB cost-bounded cache. Queued requests remain cancellation-aware, while the existing per-response byte limit and downsampling protections stay intact. Add an iOS regression that holds eight distinct requests and proves no more than the configured number can download at once. This changes no picker UI or interaction behavior. Co-authored-by: Kenny Lopez Co-authored-by: Princess Donut Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeEmojiPickerView.swift | 182 ++++++++++++++---- mobile/ios/RunnerTests/RunnerTests.swift | 99 ++++++++++ 2 files changed, 242 insertions(+), 39 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index 45713940bc9..610eb1a866f 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -355,8 +355,6 @@ struct NativeEmojiRemoteImage: View { let fallbackColor: UIColor @State private var phase: Phase = .loading - private static let maxDownloadBytes = 10 * 1024 * 1024 - private static let maxThumbnailPixels = 84 private enum Phase { case loading @@ -385,46 +383,148 @@ struct NativeEmojiRemoteImage: View { for (name, value) in requestHeaders { request.setValue(value, forHTTPHeaderField: name) } - let (bytes, response) = try await URLSession.shared.bytes(for: request) - guard - let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) - else { - phase = .failure - return - } - if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), - let byteCount = Int(contentLength), - byteCount > Self.maxDownloadBytes - { - phase = .failure - return - } - var data = Data() - let expected = httpResponse.expectedContentLength - if expected > 0 { - data.reserveCapacity( - Int(min(expected, Int64(Self.maxDownloadBytes))) - ) - } - for try await byte in bytes { - guard data.count < Self.maxDownloadBytes else { - phase = .failure - return - } - data.append(byte) - } - guard let image = Self.thumbnail(from: data) else { - phase = .failure - return - } - phase = .success(image) + phase = .success( + try await NativeEmojiRemoteImageLoader.shared.image(for: request) + ) } catch { if !Task.isCancelled { phase = .failure } } } } + private var requestIdentity: String { + url.absoluteString + } +} + +enum NativeEmojiRemoteImageError: Error { + case invalidResponse + case responseTooLarge + case invalidImage +} + +actor NativeEmojiRemoteImageLoader { + typealias Downloader = (URLRequest) async throws -> UIImage + + static let shared = NativeEmojiRemoteImageLoader() + static let defaultMaximumConcurrentDownloads = 4 + + private static let maximumDownloadBytes = 10 * 1024 * 1024 + private static let maximumThumbnailPixels = 84 + private static let defaultCacheByteLimit = 8 * 1024 * 1024 + + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private let maximumConcurrentDownloads: Int + private let downloader: Downloader + private let cache = NSCache() + private var activeDownloadCount = 0 + private var waiters: [Waiter] = [] + + init( + maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads, + cacheByteLimit: Int = defaultCacheByteLimit, + downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download + ) { + precondition(maximumConcurrentDownloads > 0) + precondition(cacheByteLimit >= 0) + self.maximumConcurrentDownloads = maximumConcurrentDownloads + self.downloader = downloader + cache.totalCostLimit = cacheByteLimit + } + + func image(for request: URLRequest) async throws -> UIImage { + let cacheKey = request as NSURLRequest + if let cached = cache.object(forKey: cacheKey) { + return cached + } + + try await acquireDownloadSlot() + defer { releaseDownloadSlot() } + + try Task.checkCancellation() + if let cached = cache.object(forKey: cacheKey) { + return cached + } + + let image = try await downloader(request) + cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image)) + return image + } + + private func acquireDownloadSlot() async throws { + try Task.checkCancellation() + guard activeDownloadCount >= maximumConcurrentDownloads else { + activeDownloadCount += 1 + return + } + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + waiters.append(Waiter(id: waiterID, continuation: continuation)) + } + } + } onCancel: { + Task { await self.cancelWaiter(id: waiterID) } + } + } + + private func cancelWaiter(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + + private func releaseDownloadSlot() { + while !waiters.isEmpty { + let waiter = waiters.removeFirst() + waiter.continuation.resume() + return + } + activeDownloadCount -= 1 + } + + private static func download(_ request: URLRequest) async throws -> UIImage { + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + throw NativeEmojiRemoteImageError.invalidResponse + } + if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let byteCount = Int(contentLength), + byteCount > maximumDownloadBytes + { + throw NativeEmojiRemoteImageError.responseTooLarge + } + + var data = Data() + let expected = httpResponse.expectedContentLength + if expected > 0 { + data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes)))) + } + for try await byte in bytes { + guard data.count < maximumDownloadBytes else { + throw NativeEmojiRemoteImageError.responseTooLarge + } + data.append(byte) + } + try Task.checkCancellation() + guard let image = thumbnail(from: data) else { + throw NativeEmojiRemoteImageError.invalidImage + } + return image + } + private static func thumbnail(from data: Data) -> UIImage? { guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil @@ -432,7 +532,7 @@ struct NativeEmojiRemoteImage: View { let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels, + kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels, kCGImageSourceShouldCacheImmediately: true, ] guard @@ -447,7 +547,11 @@ struct NativeEmojiRemoteImage: View { return UIImage(cgImage: image) } - private var requestIdentity: String { - url.absoluteString + private static func cacheCost(for image: UIImage) -> Int { + guard let cgImage = image.cgImage else { return 0 } + let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow( + by: cgImage.height + ) + return overflow ? Int.max : cost } } diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 5f3b45f36b4..f856ae4ff21 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -456,6 +456,47 @@ class RunnerTests: XCTestCase { ) } + func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws { + let maximumConcurrentDownloads = 3 + let probe = NativeEmojiDownloadProbe() + let loader = NativeEmojiRemoteImageLoader( + maximumConcurrentDownloads: maximumConcurrentDownloads, + cacheByteLimit: 0 + ) { _ in + await probe.holdDownload() + return UIImage() + } + let tasks = (0..<8).map { index in + Task { + try await loader.image( + for: URLRequest( + url: try XCTUnwrap(URL(string: "https://example.com/\(index).png")) + ) + ) + } + } + + await probe.waitUntilStarted(maximumConcurrentDownloads) + try await Task.sleep(nanoseconds: 50_000_000) + var snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.started, maximumConcurrentDownloads) + XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads) + + for expectedStarted in (maximumConcurrentDownloads + 1)...tasks.count { + await probe.releaseOne() + await probe.waitUntilStarted(expectedStarted) + } + await probe.releaseAll() + for task in tasks { + _ = try await task.value + } + + snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.started, tasks.count) + XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads) + XCTAssertEqual(snapshot.active, 0) + } + private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage { let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3)) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) @@ -683,3 +724,61 @@ private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16 | UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3]) } + +private actor NativeEmojiDownloadProbe { + private struct MilestoneWaiter { + let count: Int + let continuation: CheckedContinuation + } + + private var active = 0 + private var peakActive = 0 + private var started = 0 + private var releaseContinuations: [CheckedContinuation] = [] + private var milestoneWaiters: [MilestoneWaiter] = [] + + func holdDownload() async { + active += 1 + started += 1 + peakActive = max(peakActive, active) + resumeReachedMilestones() + await withCheckedContinuation { continuation in + releaseContinuations.append(continuation) + } + active -= 1 + } + + func waitUntilStarted(_ count: Int) async { + guard started < count else { return } + await withCheckedContinuation { continuation in + milestoneWaiters.append( + MilestoneWaiter(count: count, continuation: continuation) + ) + } + } + + func releaseOne() { + guard !releaseContinuations.isEmpty else { return } + releaseContinuations.removeFirst().resume() + } + + func releaseAll() { + let continuations = releaseContinuations + releaseContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + + func snapshot() -> (active: Int, peakActive: Int, started: Int) { + (active, peakActive, started) + } + + private func resumeReachedMilestones() { + let reached = milestoneWaiters.filter { $0.count <= started } + milestoneWaiters.removeAll { $0.count <= started } + for waiter in reached { + waiter.continuation.resume() + } + } +} From 992e8bd5c0e635526c2f236ad23edbc9804209f4 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Mon, 17 Aug 2026 18:49:57 +0100 Subject: [PATCH 09/12] Complete rejected emoji picker lifecycles Complete a reentrant iOS picker caller immediately instead of leaving its open-state callback stranded, while preserving ownership of the live native sheet. Make the native download concurrency regression wait until every task has attempted admission before checking the active bound, removing the timing-based sleep. Co-authored-by: Kenny Lopez Co-authored-by: Princess Donut Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeEmojiPickerView.swift | 8 +++++++ mobile/ios/RunnerTests/RunnerTests.swift | 21 ++++++++++++------- .../emoji_picker/ios_native_picker.dart | 10 ++++++--- .../features/channels/emoji_picker_test.dart | 8 ++++--- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index 610eb1a866f..ebed6e73f3e 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -420,6 +420,7 @@ actor NativeEmojiRemoteImageLoader { private let maximumConcurrentDownloads: Int private let downloader: Downloader + private let admissionAttemptForTesting: (() -> Void)? private let cache = NSCache() private var activeDownloadCount = 0 private var waiters: [Waiter] = [] @@ -427,11 +428,13 @@ actor NativeEmojiRemoteImageLoader { init( maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads, cacheByteLimit: Int = defaultCacheByteLimit, + admissionAttemptForTesting: (() -> Void)? = nil, downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download ) { precondition(maximumConcurrentDownloads > 0) precondition(cacheByteLimit >= 0) self.maximumConcurrentDownloads = maximumConcurrentDownloads + self.admissionAttemptForTesting = admissionAttemptForTesting self.downloader = downloader cache.totalCostLimit = cacheByteLimit } @@ -442,6 +445,7 @@ actor NativeEmojiRemoteImageLoader { return cached } + recordAdmissionAttemptForTesting() try await acquireDownloadSlot() defer { releaseDownloadSlot() } @@ -455,6 +459,10 @@ actor NativeEmojiRemoteImageLoader { return image } + private func recordAdmissionAttemptForTesting() { + admissionAttemptForTesting?() + } + private func acquireDownloadSlot() async throws { try Task.checkCancellation() guard activeDownloadCount >= maximumConcurrentDownloads else { diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index f856ae4ff21..a6ccdde99bc 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -458,15 +458,22 @@ class RunnerTests: XCTestCase { func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws { let maximumConcurrentDownloads = 3 + let taskCount = 8 let probe = NativeEmojiDownloadProbe() + let tasksAttemptedAdmission = XCTestExpectation( + description: "all download tasks attempted admission" + ) + tasksAttemptedAdmission.expectedFulfillmentCount = taskCount let loader = NativeEmojiRemoteImageLoader( maximumConcurrentDownloads: maximumConcurrentDownloads, - cacheByteLimit: 0 - ) { _ in - await probe.holdDownload() - return UIImage() - } - let tasks = (0..<8).map { index in + cacheByteLimit: 0, + admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() }, + downloader: { _ in + await probe.holdDownload() + return UIImage() + } + ) + let tasks = (0.. _presentIosEmojiPicker({ required void Function(String emoji) onSelect, VoidCallback? onDismiss, }) async { - // Only one native sheet owns the handler at a time; coalesce a reentrant open - // so it cannot steal the live sheet's callbacks from its original owner. - if (_iosEmojiPickerPresenting) return; + // Only one native sheet owns the handler at a time. Reject a reentrant open + // without replacing the live sheet's callbacks, and complete the rejected + // caller so its local picker-open lifecycle is not stranded. + if (_iosEmojiPickerPresenting) { + onDismiss?.call(); + return; + } _iosEmojiPickerPresenting = true; final container = ProviderScope.containerOf(context, listen: false); diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 8c7c05ef577..f64932f0a39 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -941,11 +941,13 @@ void main() { await tester.pumpAndSettle(); expect(presents, 1); - // A second open while the first sheet is live is coalesced: it neither - // presents again nor replaces the live sheet's method-call handler. + // A second open while the first sheet is live is rejected: it neither + // presents again nor replaces the live sheet's method-call handler, + // and its independent lifecycle is completed immediately. await tester.tap(find.text('Open second')); await tester.pumpAndSettle(); expect(presents, 1); + expect(secondDismissals, 1); // Native events still reach the original owner, and only it. await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); @@ -953,7 +955,7 @@ void main() { expect(firstSelected, ['\u{1F525}']); expect(secondSelected, isEmpty); expect(firstDismissals, 1); - expect(secondDismissals, 0); + expect(secondDismissals, 1); } finally { _setMockNativeEmojiPickerHandler(null); debugDefaultTargetPlatformOverride = previousPlatform; From b57a66d5d16015089bc8fee4a3898669d2b3d251 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Tue, 18 Aug 2026 07:14:15 +0100 Subject: [PATCH 10/12] Keep the emoji category rail in sync with the viewed section on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rail-highlight desyncs surfaced in review: - Android: changing skin tone rebuilds the sections and the active-section notifier, which was recreated at index 0 while the grid kept its scroll offset — so a user parked on a later category snapped the rail back to the first one until the next scroll. Seed the rebuilt notifier from the live scroll offset instead. - iOS: a final section shorter than the viewport can never scroll its header to the top, so at the clamped bottom the header-at-top rule left the preceding category highlighted (and announced as selected to VoiceOver). Report the viewport height and content bottom alongside the section offsets so the tracker highlights the last section when the content end is on screen and the list has overflowed. Fixing the highlight fixes the .isSelected accessibility trait, which mirrors it. No change to the authored picker look or interaction otherwise. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez Signed-off-by: kenny lopez --- .../ios/Runner/NativeEmojiPickerModel.swift | 30 ++++++++- mobile/ios/Runner/NativeEmojiPickerView.swift | 33 +++++++++- mobile/ios/RunnerTests/RunnerTests.swift | 66 +++++++++++++++++++ .../lib/features/channels/emoji_picker.dart | 14 +++- .../features/channels/emoji_picker_test.dart | 37 +++++++++++ 5 files changed, 177 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift index bf7e920fcd4..75bf46446cd 100644 --- a/mobile/ios/Runner/NativeEmojiPickerModel.swift +++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift @@ -373,6 +373,14 @@ enum NativeEmojiSearch { /// The top offset of each pinned section header, keyed by section id, reported /// up from the scrolling grid so the rail can follow manual scrolling. +/// +/// The same stream also carries two viewport measurements under the reserved +/// keys below, so the tracker sees the section offsets and the viewport bounds +/// consistently in a single update. Section ids come from the emoji dataset and +/// never collide with these dotted reserved keys. +let nativeEmojiViewportBottomKey = "buzz.emoji.viewportBottom" +let nativeEmojiContentBottomKey = "buzz.emoji.contentBottom" + struct NativeEmojiSectionOffsetsKey: PreferenceKey { static let defaultValue: [String: CGFloat] = [:] @@ -391,8 +399,28 @@ enum NativeEmojiCategoryTracker { static func selectedSectionID( order: [String], offsets: [String: CGFloat], - viewportTop: CGFloat + viewportTop: CGFloat, + viewportBottom: CGFloat? = nil, + contentBottom: CGFloat? = nil ) -> String? { + // At the clamped bottom of an overflowing list, a final section shorter + // than the viewport can never scroll its header to the top, so the + // header-at-top rule would keep the preceding section highlighted while the + // user is plainly viewing the last one. Detect that case first: the content + // end is on screen (`contentBottom <= viewportBottom`) and the top has + // scrolled away (`firstTop < viewportTop`, so the list really did overflow + // rather than merely fitting). Highlight the last section then. + if let viewportBottom, + let contentBottom, + contentBottom <= viewportBottom + 1, + let firstID = order.first, + let firstTop = offsets[firstID], + firstTop < viewportTop, + let lastID = order.last + { + return lastID + } + var selected: String? for id in order { guard let top = offsets[id] else { continue } diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index ebed6e73f3e..a71de4b815a 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -295,15 +295,19 @@ struct NativeEmojiPickerView: View { } } .padding(.bottom, 8) + .background(contentBoundaryReporter()) } .coordinateSpace(name: sectionListSpace) + .background(viewportBoundaryReporter()) .scrollDismissesKeyboard(.interactively) .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in guard tracksSelection else { return } selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID( order: data.sections.map(\.id), offsets: offsets, - viewportTop: 0 + viewportTop: 0, + viewportBottom: offsets[nativeEmojiViewportBottomKey], + contentBottom: offsets[nativeEmojiContentBottomKey] ) } } @@ -317,6 +321,33 @@ struct NativeEmojiPickerView: View { } } + // The end of the scrolling content, relative to the viewport top. At the + // clamped bottom of an overflowing list this converges on the viewport + // height, which lets the tracker highlight a short final section that can + // never scroll its own header to the top. + private func contentBoundaryReporter() -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [ + nativeEmojiContentBottomKey: + geometry.frame(in: .named(sectionListSpace)).maxY + ] + ) + } + } + + // The fixed viewport height, reported through the same preference stream so + // it stays consistent with the section offsets in each update. + private func viewportBoundaryReporter() -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [nativeEmojiViewportBottomKey: geometry.size.height] + ) + } + } + private func emojiButton(_ item: NativeEmojiItem) -> some View { let value = displayValue(for: item) return Button { diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index a6ccdde99bc..2c43056c4c0 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -456,6 +456,72 @@ class RunnerTests: XCTestCase { ) } + func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() { + // The list has overflowed (People scrolled above the top) and its end is on + // screen, but the short Custom section's header sits below the top because + // the content clamps before it can reach it. The rail must still highlight + // Custom rather than leaving Nature — its predecessor — selected. + let order = ["people", "nature", "custom"] + let offsets: [String: CGFloat] = [ + "people": -900, + "nature": -420, + "custom": 360, + ] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0, + viewportBottom: 500, + contentBottom: 500 + ), + "custom" + ) + } + + func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() { + // The same short-final geometry, but the content end is still below the + // viewport (the user has not reached the bottom), so the ordinary + // header-at-top rule applies and Nature stays selected. + let order = ["people", "nature", "custom"] + let offsets: [String: CGFloat] = [ + "people": -900, + "nature": -420, + "custom": 360, + ] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0, + viewportBottom: 500, + contentBottom: 900 + ), + "nature" + ) + } + + func testCategoryTrackerDoesNotForceLastSectionForAShortList() { + // A list that fits without scrolling has its content end on screen too, but + // its first header is still at the top — so the bottom rule must not fire + // and steal the highlight to the final section. + let order = ["people", "nature"] + let offsets: [String: CGFloat] = ["people": 0, "nature": 120] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0, + viewportBottom: 500, + contentBottom: 240 + ), + "people" + ) + } + func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws { let maximumConcurrentDownloads = 3 let taskCount = 8 diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index 81d889646d4..06aa17a0285 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -146,7 +146,19 @@ class _EmojiPickerContent extends HookConsumerWidget { // A notifier rather than state: the highlight changes on every scroll frame // and only the rail needs to hear about it. Rebuilding the sheet would // rebuild the grid underneath it. - final activeSection = useMemoized(() => ValueNotifier(0), [sections]); + // + // Seed it from the current scroll offset rather than 0: a skin-tone change + // rebuilds [sections] and so replaces this notifier, but the grid keeps its + // scroll position (same controller, same section extents). Resetting to 0 + // here would falsely highlight the first category until the next scroll. + final activeSection = useMemoized( + () => ValueNotifier( + scrollController.hasClients + ? _activeSectionIndex(offsets, scrollController.offset) + : 0, + ), + [sections], + ); useEffect(() => activeSection.dispose, [activeSection]); useEffect(() { diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index f64932f0a39..0c1d8c6da88 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -497,6 +497,43 @@ void main() { expect(find.byTooltip('Smileys & People'), findsOneWidget); }); + testWidgets( + 'changing skin tone keeps the scrolled-to category highlighted', + (tester) async { + // A skin-tone change rebuilds the sections and the active-section + // notifier. Regression: the notifier was recreated at index 0, so a + // user parked on a later category snapped back to the first one in the + // rail while the grid stayed put. The notifier now seeds from the live + // scroll offset, so the highlight survives the rebuild. + await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset); + final colors = Theme.of( + tester.element(find.byType(EmojiPickerSheet)), + ).colorScheme; + Color iconColor(String tooltip) => tester + .widget( + find.descendant( + of: find.byTooltip(tooltip), + matching: find.byType(Icon), + ), + ) + .color!; + + await tester.tap(find.byTooltip('Animals & Nature')); + await tester.pumpAndSettle(); + expect(iconColor('Animals & Nature'), colors.primary); + expect(iconColor('Smileys & People'), colors.onSurfaceVariant); + + await tester.tap(find.byTooltip('Skin tone')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3'))); + await tester.pumpAndSettle(); + + // Still on Nature after the tone rebuild — not reset to People. + expect(iconColor('Animals & Nature'), colors.primary); + expect(iconColor('Smileys & People'), colors.onSurfaceVariant); + }, + ); + testWidgets('a standard emoji emits its glyph', (tester) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); From 2dc61ab93f2265c28819bf044395caf42e3da289 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Tue, 18 Aug 2026 19:48:18 +0100 Subject: [PATCH 11/12] Keep the final Android emoji category selected Treat the clamped bottom of an overflowing emoji grid as belonging to its final section, so a short Custom section retains the rail highlight and selected accessibility state after scrolling settles. Add a widget regression that exercises the rail tap, verifies the scroll target is bottom-clamped, and checks both Custom and the preceding category colors. Co-authored-by: Princess Donut Co-authored-by: Kenny Lopez Signed-off-by: Kenny Lopez --- .../lib/features/channels/emoji_picker.dart | 7 ++++- .../channels/emoji_picker/emoji_grid.dart | 14 ++++++++- .../features/channels/emoji_picker_test.dart | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index 06aa17a0285..b66d526e7ee 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -154,7 +154,11 @@ class _EmojiPickerContent extends HookConsumerWidget { final activeSection = useMemoized( () => ValueNotifier( scrollController.hasClients - ? _activeSectionIndex(offsets, scrollController.offset) + ? _activeSectionIndex( + offsets, + scrollController.offset, + maxScrollExtent: scrollController.position.maxScrollExtent, + ) : 0, ), [sections], @@ -167,6 +171,7 @@ class _EmojiPickerContent extends HookConsumerWidget { activeSection.value = _activeSectionIndex( offsets, scrollController.offset, + maxScrollExtent: scrollController.position.maxScrollExtent, ); } diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart index 7f13e3b1282..eb7217be027 100644 --- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart +++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart @@ -63,7 +63,19 @@ List _sectionOffsets(List<_EmojiSection> sections) { } /// Which section owns [offset] — the one whose header is pinned right now. -int _activeSectionIndex(List offsets, double offset) { +/// At the clamped bottom, the final visible section owns the viewport even when +/// it is too short for its header to reach the top. +int _activeSectionIndex( + List offsets, + double offset, { + required double maxScrollExtent, +}) { + if (offsets.isEmpty) return 0; + if (maxScrollExtent > 0 && + offset >= maxScrollExtent - precisionErrorTolerance) { + return offsets.length - 1; + } + var active = 0; for (var i = 0; i < offsets.length; i++) { // Half a header of slack so the highlight flips as a header reaches the diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 0c1d8c6da88..f1b1f3c0737 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -381,6 +381,37 @@ void main() { expect(offset(), closeTo(28 + 25 * 40, 0.5)); }); + testWidgets( + 'a bottom-clamped final section stays highlighted after a rail tap', + (tester) async { + await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset); + final colors = Theme.of( + tester.element(find.byType(EmojiPickerSheet)), + ).colorScheme; + Color iconColor(String tooltip) => tester + .widget( + find.descendant( + of: find.byTooltip(tooltip), + matching: find.byType(Icon), + ), + ) + .color!; + + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); + + final grid = tester.widget( + find.byKey(const ValueKey('emoji-picker-grid')), + ); + expect( + grid.controller!.offset, + closeTo(grid.controller!.position.maxScrollExtent, 0.5), + ); + expect(iconColor('Custom'), colors.primary); + expect(iconColor('Animals & Nature'), colors.onSurfaceVariant); + }, + ); + testWidgets('the custom section only exists when the palette has emoji', ( tester, ) async { From 8378c2bf2a434e4038509e256b506de0714fd1d2 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 15:20:38 -0600 Subject: [PATCH 12/12] Show feedback while iOS emoji palette loads Present a cancellable loading sheet immediately when the custom emoji palette is unresolved, then hand off to the native picker once the palette arrives. Cover visible feedback, completion, and cancellation ownership. Co-authored-by: Carl Signed-off-by: Wes --- .../emoji_picker/ios_native_picker.dart | 89 ++++++++++++++++--- .../features/channels/emoji_picker_test.dart | 75 +++++++++++++++- 2 files changed, 150 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart index d3cd67e7540..2986af96360 100644 --- a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -28,21 +28,84 @@ Future _presentIosEmojiPicker({ _iosEmojiPickerPresenting = true; final container = ProviderScope.containerOf(context, listen: false); + final paletteState = container.read(customEmojiPaletteProvider); final List customEmoji; - try { - customEmoji = await container.read(customEmojiPaletteProvider.future); - } catch (_) { - // A palette fetch failure must not strand the composer's open state: fall - // back to the Flutter picker, which watches the palette itself. - _iosEmojiPickerPresenting = false; - if (context.mounted) { - _showFlutterEmojiPicker( - context: context, - onSelect: onSelect, - onDismiss: onDismiss, - ); + BuildContext? loadingSheetContext; + var leavingLoadingSheet = false; + var loadingCancelled = false; + Future? loadingSheet; + + if (paletteState case AsyncData(:final value)) { + customEmoji = value; + } else { + // Palette history can take the relay timeout to resolve. Give the tap an + // immediate, cancellable surface instead of holding the global guard while + // the composer appears unresponsive. + final loadingSheetBuilt = Completer(); + loadingSheet = + showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showCloseButton: false, + builder: (sheetContext) { + loadingSheetContext = sheetContext; + if (!loadingSheetBuilt.isCompleted) loadingSheetBuilt.complete(); + return const SizedBox( + key: Key('ios-emoji-picker-palette-loading'), + height: 180, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: Grid.sm), + Text('Loading emoji…'), + ], + ), + ), + ); + }, + ).whenComplete(() { + if (leavingLoadingSheet) return; + loadingCancelled = true; + _iosEmojiPickerPresenting = false; + onDismiss?.call(); + }); + + try { + customEmoji = await container.read(customEmojiPaletteProvider.future); + } catch (_) { + if (loadingCancelled) return; + await loadingSheetBuilt.future; + if (loadingCancelled) return; + leavingLoadingSheet = true; + if (loadingSheetContext case final sheetContext? + when sheetContext.mounted) { + Navigator.of(sheetContext).pop(); + } + await loadingSheet; + // A palette fetch failure must not strand the composer's open state: fall + // back to the Flutter picker, which watches the palette itself. + _iosEmojiPickerPresenting = false; + if (context.mounted) { + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); + } + return; } - return; + + if (loadingCancelled) return; + await loadingSheetBuilt.future; + if (loadingCancelled) return; + leavingLoadingSheet = true; + if (loadingSheetContext case final sheetContext? + when sheetContext.mounted) { + Navigator.of(sheetContext).pop(); + } + await loadingSheet; } if (!context.mounted) { _iosEmojiPickerPresenting = false; diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index f1b1f3c0737..9c1cf556169 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -787,7 +787,7 @@ void main() { } }); - testWidgets('awaits the custom emoji palette before presenting', ( + testWidgets('shows cancellable feedback while loading the custom palette', ( tester, ) async { final previousPlatform = debugDefaultTargetPlatformOverride; @@ -827,9 +827,18 @@ void main() { await tester.tap(find.text('Open picker')); await tester.pump(); expect(presentation, isNull); + expect( + find.byKey(const Key('ios-emoji-picker-palette-loading')), + findsOneWidget, + ); + expect(find.text('Loading emoji…'), findsOneWidget); palette.complete(_customEmoji); await tester.pumpAndSettle(); + expect( + find.byKey(const Key('ios-emoji-picker-palette-loading')), + findsNothing, + ); expect(presentation?.method, 'present'); } finally { _setMockNativeEmojiPickerHandler(null); @@ -837,6 +846,70 @@ void main() { } }); + testWidgets('cancels palette loading without presenting native picker', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + final palette = Completer>(); + var presents = 0; + var dismissals = 0; + _setMockNativeEmojiPickerHandler((call) async { + if (call.method == 'present') presents += 1; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier(palette.future), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: (_) {}, + onDismiss: () => dismissals += 1, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byKey(const Key('ios-emoji-picker-palette-loading')), + findsOneWidget, + ); + + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + expect(dismissals, 1); + expect(presents, 0); + + palette.complete(_customEmoji); + await tester.pumpAndSettle(); + expect(presents, 0); + expect(dismissals, 1); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('falls back to the Flutter picker when native cannot present', ( tester, ) async {