mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## Summary - Align mobile home avatars and share the same sticky-date and Latest controls across channels and threads. - Stabilize thread tail navigation and emoji category scrolling. - Remove iOS glass compositing artifacts and match long-press blur across action surfaces. ## Why Threads and emoji selection used competing scroll and compositing paths, causing rebounds, flicker, and inconsistent controls. ## Testing - `just mobile-check` - `flutter test` (1,529 tests) - Signed Release installed, launched, and reviewed on a connected iPhone --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz> Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
493 lines
15 KiB
Swift
493 lines
15 KiB
Swift
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<String> = []
|
|
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.
|
|
///
|
|
/// 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] = [:]
|
|
|
|
static func reduce(
|
|
value: inout [String: CGFloat],
|
|
nextValue: () -> [String: CGFloat]
|
|
) {
|
|
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
|
}
|
|
}
|
|
|
|
/// Keeps scroll-driven category selection out of the picker view's own state.
|
|
/// The category buttons observe this object directly, so updating the rail does
|
|
/// not invalidate and rebuild the scroll container underneath an active drag.
|
|
final class NativeEmojiCategorySelection: ObservableObject {
|
|
@Published private(set) var selectedSectionID: String?
|
|
|
|
init(initialSectionID: String?) {
|
|
selectedSectionID = initialSectionID
|
|
}
|
|
|
|
func select(_ sectionID: String?) {
|
|
guard sectionID != selectedSectionID else { return }
|
|
selectedSectionID = sectionID
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
viewportBottom: CGFloat? = nil,
|
|
contentBottom: CGFloat? = nil,
|
|
currentSelection: String? = 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 }
|
|
// 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
|
|
}
|
|
}
|
|
|
|
let candidate = selected ?? currentSelection ?? order.first
|
|
guard
|
|
let candidate,
|
|
let currentSelection,
|
|
let candidateIndex = order.firstIndex(of: candidate),
|
|
let currentIndex = order.firstIndex(of: currentSelection),
|
|
candidateIndex < currentIndex
|
|
else {
|
|
return candidate
|
|
}
|
|
|
|
// Pinned headers can briefly report competing or incomplete positions as
|
|
// one section pushes another off the top. Once the next section is active,
|
|
// retain it through that small boundary jitter. A real upward scroll moves
|
|
// its header clearly back into the viewport and then releases the latch.
|
|
guard let currentTop = offsets[currentSelection] else {
|
|
// A LazyVStack can discard the old pinned header after a fast upward
|
|
// fling. Once an earlier header is a valid candidate, absence of the old
|
|
// header is evidence to release the latch rather than retain it forever.
|
|
return candidate
|
|
}
|
|
if currentTop <= viewportTop + 8 {
|
|
return currentSelection
|
|
}
|
|
|
|
// The final, short section is selected from the content boundary rather
|
|
// than its header. Keep that bottom selection stable until the content end
|
|
// has visibly moved away from the viewport edge.
|
|
if currentSelection == order.last {
|
|
guard let viewportBottom, let contentBottom else {
|
|
return currentSelection
|
|
}
|
|
if contentBottom <= viewportBottom + 8 {
|
|
return currentSelection
|
|
}
|
|
}
|
|
|
|
return candidate
|
|
}
|
|
}
|