Files
buzz/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift
Tom Brow 4a101752fb fix(mobile): never show emoji shortcodes in reaction pills (#9411)
## 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>
2026-11-12 13:38:37 -08:00

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)
}
}