mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
## Context A reaction slot should never show an emoji shortcode as text. Today it does in two cases: - An agent's reaction is a kind 7 event with content `:shortcode:` and no NIP-30 `emoji` tag, so the pill printed `:approve:` in tiny text instead of the community emoji. - A custom emoji whose image fails to load fell back to the shortcode text. ## Behavior - A tagless `:shortcode:` reaction resolves against the community custom-emoji palette and renders that image. An `emoji` tag on the event still wins. - A shortcode with no palette entry, or whose image fails to load, shows a neutral placeholder glyph (the muted sparkles icon) instead of text. The shortcode survives only as the semantics label. - This applies to every reaction slot: the timeline pills, the combined reaction tray, the Flutter reaction detail sheet, and the native iOS reactions sheet (which is told which shortcodes have no image, so `NativeMessageGlyph` draws the placeholder). System-message rows reuse the same reaction row. - Unicode emoji are unchanged. The icon is the existing `BuzzIcons.sparkles`. Pareto note: the palette lookup is the shared `reactionEmojiUrl` helper, so there is no new resolver, and the pill only watches the palette when the reaction has no tag. ### Validation - `just mobile-check mobile-test`: format and analyze clean; full suite passes (4773 passed, 74 skipped). - New widget tests (journeys, in `reaction_row_test/shortcode_tests.dart`): a tagless known shortcode renders its image; an unknown shortcode and a failed image render the placeholder and never the text; unicode is unchanged; the native sheet payload marks image-less shortcodes. Reverting the fix makes both new journeys fail. - iOS: added a Swift unit test for the native glyph placeholder (`NativeMessageImageLoaderTests`); not run locally, the app run below exercises the same code. - Automated app run on a cloned iOS simulator with a fixture entry point (mocked media, synthetic palette): confirmed the pills before and after, and opened the reaction sheet from a long-press to confirm the placeholder renders there too. The clone was deleted afterwards. ## Before / after Captures use synthetic shortcodes and a synthetic palette. <img width="300" alt="reaction-pills-before" src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-pills-before.png"> <img width="300" alt="reaction-pills-after" src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-pills-after.png"> Reaction sheet after: <img width="300" alt="reaction-sheet-after" src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-sheet-after.png"> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
202 lines
9.5 KiB
Swift
202 lines
9.5 KiB
Swift
import UIKit
|
|
import XCTest
|
|
@testable import Buzz
|
|
|
|
final class NativeMessageImageLoaderTests: XCTestCase {
|
|
private func loader(limit: Int = 1) -> NativeMessageImageLoader {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [MessageImageProtocol.self]
|
|
return NativeMessageImageLoader(configuration: configuration, maximumConcurrent: limit)
|
|
}
|
|
|
|
@MainActor func testRejectsDeclaredOversizeWithoutFinishingBody() async {
|
|
let done = expectation(description: "rejected")
|
|
MessageImageProtocol.start = { transport in
|
|
transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse(
|
|
url: transport.request.url!, statusCode: 200, httpVersion: nil,
|
|
headerFields: ["Content-Length": "\(NativeMessageImageLoader.maximumBytes + 1)", "Content-Type": "image/png"]
|
|
)!, cacheStoragePolicy: .notAllowed)
|
|
// Let URLProtocol deliver its headers without finishing the body. A
|
|
// completion-handler byte check would hang here, rather than reject.
|
|
transport.client?.urlProtocol(transport, didLoad: Data(count: 1024))
|
|
}
|
|
let loader = loader()
|
|
_ = loader.load(URLRequest(url: URL(string: "https://example.com/declared")!)) { image in
|
|
XCTAssertNil(image)
|
|
done.fulfill()
|
|
}
|
|
await fulfillment(of: [done], timeout: 2)
|
|
}
|
|
|
|
@MainActor func testRejectsChunkedOversizeWithoutWaitingForCompletion() async {
|
|
let done = expectation(description: "stream cancelled")
|
|
MessageImageProtocol.start = { transport in
|
|
transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse(
|
|
url: transport.request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
|
|
)!, cacheStoragePolicy: .notAllowed)
|
|
transport.client?.urlProtocol(transport, didLoad: Data(count: NativeMessageImageLoader.maximumBytes))
|
|
transport.client?.urlProtocol(transport, didLoad: Data([0]))
|
|
// No finish callback: the streaming byte guard must terminate the load.
|
|
}
|
|
let loader = loader()
|
|
_ = loader.load(URLRequest(url: URL(string: "https://example.com/chunked")!)) { image in
|
|
XCTAssertNil(image)
|
|
done.fulfill()
|
|
}
|
|
await fulfillment(of: [done], timeout: 2)
|
|
}
|
|
|
|
@MainActor func testDeduplicatesAndCancellationReleasesAdmission() async {
|
|
let first = expectation(description: "first admitted")
|
|
let second = expectation(description: "second admitted after cancellation")
|
|
var started = [String]()
|
|
MessageImageProtocol.start = { transport in
|
|
DispatchQueue.main.async {
|
|
started.append(transport.request.url!.path)
|
|
if started.count == 1 { first.fulfill() } else { second.fulfill() }
|
|
}
|
|
}
|
|
let loader = loader()
|
|
let request = URLRequest(url: URL(string: "https://example.com/shared")!)
|
|
let cancelFirst = loader.load(request) { _ in XCTFail("cancelled subscriber called") }
|
|
let cancelDuplicate = loader.load(request) { _ in XCTFail("cancelled duplicate called") }
|
|
let cancelQueued = loader.load(URLRequest(url: URL(string: "https://example.com/queued")!)) { _ in
|
|
XCTFail("cancelled queued subscriber called")
|
|
}
|
|
let cancelNext = loader.load(URLRequest(url: URL(string: "https://example.com/next")!)) { _ in }
|
|
await fulfillment(of: [first], timeout: 2)
|
|
XCTAssertEqual(started, ["/shared"])
|
|
cancelFirst()
|
|
cancelQueued()
|
|
// One subscriber remains: cancelling the first must not cancel its peer.
|
|
let turn = expectation(description: "cancellation processed")
|
|
DispatchQueue.main.async { turn.fulfill() }
|
|
await fulfillment(of: [turn], timeout: 2)
|
|
XCTAssertEqual(started, ["/shared"])
|
|
cancelDuplicate()
|
|
await fulfillment(of: [second], timeout: 2)
|
|
XCTAssertEqual(started, ["/shared", "/next"])
|
|
cancelNext()
|
|
}
|
|
|
|
@MainActor func testCachesDecodedImages() async {
|
|
let done = expectation(description: "decoded")
|
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).pngData { _ in }
|
|
var requests = 0
|
|
MessageImageProtocol.start = { transport in
|
|
requests += 1
|
|
transport.client?.urlProtocol(transport, didReceive: HTTPURLResponse(
|
|
url: transport.request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
|
|
)!, cacheStoragePolicy: .notAllowed)
|
|
transport.client?.urlProtocol(transport, didLoad: image)
|
|
transport.client?.urlProtocolDidFinishLoading(transport)
|
|
}
|
|
let loader = loader()
|
|
let request = URLRequest(url: URL(string: "https://example.com/cached")!)
|
|
_ = loader.load(request) { image in XCTAssertNotNil(image); done.fulfill() }
|
|
await fulfillment(of: [done], timeout: 2)
|
|
var cached = false
|
|
_ = loader.load(request) { image in cached = image != nil }
|
|
XCTAssertTrue(cached)
|
|
XCTAssertEqual(requests, 1)
|
|
}
|
|
}
|
|
|
|
private final class MessageImageProtocol: URLProtocol {
|
|
static var start: ((MessageImageProtocol) -> Void)?
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
override func startLoading() { Self.start?(self) }
|
|
override func stopLoading() {}
|
|
}
|
|
|
|
|
|
final class NativeReactionDetailsTests: XCTestCase {
|
|
@MainActor func testInlineEmojiAvatarRendersEmojiAndBackground() {
|
|
let controller = NativeReactionDetailsViewController(data: [
|
|
"reactions": [["emoji": "❤️", "label": "Heart", "users": ["person"]]],
|
|
"profiles": ["person": ["name": "Kenny", "avatarEmoji": "😊", "avatarColor": NSNumber(value: UInt32(0xFFFF6B9A))]],
|
|
])
|
|
let cell = controller.tableView(UITableView(), cellForRowAt: IndexPath(row: 0, section: 0))
|
|
let row = cell.contentView.subviews.compactMap { $0 as? UIStackView }.first!
|
|
let avatar = row.arrangedSubviews[0] as! NativeMessageGlyph
|
|
XCTAssertEqual(avatar.subviews.compactMap { $0 as? UILabel }.first?.text, "😊")
|
|
XCTAssertEqual(avatar.backgroundColor, UIColor(red: 1, green: 107.0 / 255, blue: 154.0 / 255, alpha: 1))
|
|
controller.updateProfiles(["person": ["name": "Kenny", "avatarEmoji": "🎉", "avatarColor": NSNumber(value: UInt32(0xFFFFE75C))]])
|
|
let updated = controller.tableView(UITableView(), cellForRowAt: IndexPath(row: 0, section: 0))
|
|
let updatedRow = updated.contentView.subviews.compactMap { $0 as? UIStackView }.first!
|
|
XCTAssertEqual(updatedRow.arrangedSubviews[0].subviews.compactMap { $0 as? UILabel }.first?.text, "🎉")
|
|
}
|
|
|
|
@MainActor func testShortcodeReactionWithoutImageNeverShowsText() {
|
|
let glyph = NativeMessageGlyph(
|
|
data: ["emoji": ":ghost:", "isShortcode": true], size: 26, isReaction: true)
|
|
XCTAssertNil(glyph.subviews.compactMap { $0 as? UILabel }.first?.text)
|
|
XCTAssertNotNil(glyph.subviews.compactMap { $0 as? UIImageView }.first?.image)
|
|
}
|
|
|
|
@MainActor func testTogglesListEveryReactorAndRelabelUpdatedProfiles() {
|
|
let controller = NativeReactionDetailsViewController(data: [
|
|
"reactions": [
|
|
["emoji": "❤️", "label": "Heart", "count": 2, "users": ["human", "agent"]],
|
|
["emoji": "🔥", "label": "Fire", "count": 1, "users": ["agent"]],
|
|
],
|
|
"profiles": ["human": ["name": "Honey"], "agent": ["name": "Honey (agent)"]],
|
|
])
|
|
var toggled: [String] = []
|
|
controller.onToggle = { toggled.append($0) }
|
|
controller.loadViewIfNeeded()
|
|
let table = UITableView()
|
|
func labels() -> [String] {
|
|
(0..<controller.tableView(table, numberOfRowsInSection: 0)).map {
|
|
controller.tableView(table, cellForRowAt: IndexPath(row: $0, section: 0)).accessibilityLabel ?? ""
|
|
}
|
|
}
|
|
func descendants(_ view: UIView) -> [UIView] {
|
|
view.subviews.flatMap { [$0] + descendants($0) }
|
|
}
|
|
func tap(_ label: String) {
|
|
let button = descendants(controller.view).compactMap { $0 as? UIButton }
|
|
.first { $0.accessibilityLabel == label }
|
|
XCTAssertNotNil(button, label)
|
|
button?.sendActions(for: .primaryActionTriggered)
|
|
}
|
|
XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart", "Honey (agent), Fire"])
|
|
tap("Fire 1")
|
|
tap("Heart 2")
|
|
XCTAssertEqual(toggled, ["🔥", "❤️"])
|
|
controller.updateProfiles(["agent": ["name": "Helper (agent)"]])
|
|
XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart", "Helper (agent), Fire"])
|
|
}
|
|
}
|
|
|
|
|
|
final class NativeMessageLayoutTests: XCTestCase {
|
|
private func descendants(_ view: UIView) -> [UIView] {
|
|
view.subviews.flatMap { [$0] + descendants($0) }
|
|
}
|
|
|
|
@MainActor func testReactionFiltersFitAccessibilityText() {
|
|
let parent = UIViewController()
|
|
let controller = NativeReactionDetailsViewController(data: [
|
|
"reactions": [["emoji": "❤️", "label": "Heart", "count": 12345, "users": ["a"]]],
|
|
])
|
|
parent.addChild(controller)
|
|
parent.setOverrideTraitCollection(UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge), forChild: controller)
|
|
parent.view.addSubview(controller.view)
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
controller.view.setNeedsLayout()
|
|
controller.view.layoutIfNeeded()
|
|
let filter = descendants(controller.view).compactMap { $0 as? UIButton }
|
|
.first { $0.accessibilityLabel == "Heart 12345" }
|
|
XCTAssertNotNil(filter)
|
|
let viewport = descendants(controller.view).compactMap { $0 as? UIScrollView }
|
|
.first { !($0 is UITableView) }
|
|
XCTAssertNotNil(viewport)
|
|
let needed = filter?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 0
|
|
XCTAssertGreaterThan(needed, 48)
|
|
XCTAssertGreaterThanOrEqual(viewport?.bounds.height ?? 0, needed)
|
|
XCTAssertGreaterThanOrEqual(filter?.bounds.height ?? 0, needed)
|
|
}
|
|
}
|