mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
On iOS, custom emoji in the native emoji picker often render as the sparkle fallback icon instead of the image. The native picker downloads custom emoji with `URLSession` and asks Flutter for request headers over the `mediaHeaders` channel call. That call returned only the media read `Authorization` header. Hosted relays also require the `Nostr-Federated-Identity` assertion, which the Flutter image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get a 401 and the picker falls back to sparkles. Emoji hosted elsewhere still load, which is why only some are missing. The `mediaHeaders` bridge now also includes the hosted relay assertion via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own origin like `HostedRelayHttpClient`. The native loader refuses redirects for requests that carry the assertion, matching the Dart client, so the token can't be forwarded to another host. The iOS reactions sheet had the same gap: it passes avatar and custom emoji URLs to native views with only the `Authorization` header. A new `nativeMediaHeadersProvider` combines both headers for any native media consumer, and the reactions sheet uses it. ### Validation - Added tests: the native picker's `mediaHeaders` call returns the relay assertion for relay media and no headers for an external emoji URL; `headersForUrl` returns the assertion only for the relay origin (not another host, subdomain or port). The reactions sheet passes the combined headers for avatars and custom emoji, and `nativeMediaHeadersProvider` merges both headers only for relay URLs. - Focused existing validation: the emoji picker and organization relay auth suites pass, and `flutter analyze` is clean. In the full mobile suite, the only failures are 10 in `notification_toggle_test`, `push_bootstrap_test` and `push_bridge_test`, and they fail the same way on `main`. - Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build of this branch with hosted auth enabled, signed in to two hosted communities through a cloned simulator keychain, driven with AXe). Covered: launch signed in, channel and DM lists, opening a channel and a DM, the native emoji picker (its Custom section shows the emoji as an image, not the sparkle fallback), sending a custom emoji message and seeing it render, reacting with the custom emoji (shown in the quick-reaction row and as a reaction pill), opening a thread, switching communities, avatars and community icons, background and resume, and a cold relaunch. Result: no crashes or regressions seen. Screenshots are omitted because they show real account and community names. - Limitations: this run does not reproduce the original bug. The hosted community's custom emoji also loaded in a temporary build with the relay assertion removed from the `mediaHeaders` bridge, so its emoji URL evidently doesn't need the assertion, and the run doesn't prove the fix. The reactions detail sheet could not be reached because simulated long presses on the pill opened the message menu, and inline image attachments were not exercised. A device build against a community whose emoji are relay-hosted is still needed to confirm the fix. 🤖 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>
654 lines
20 KiB
Swift
654 lines
20 KiB
Swift
import ImageIO
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct NativeEmojiPickerView: View {
|
|
let data: NativeEmojiPickerData
|
|
let appearance: NativeEmojiPickerAppearance
|
|
let largeEmoji: Bool
|
|
let onSelect: (String) -> Void
|
|
let onSkinToneChanged: (Int) -> Void
|
|
let onClose: () -> Void
|
|
|
|
@State private var query = ""
|
|
@State private var categorySelection: NativeEmojiCategorySelection
|
|
@State private var selectedSkinTone: Int
|
|
|
|
private var columns: [GridItem] {
|
|
Array(
|
|
repeating: GridItem(.flexible(minimum: 36), spacing: 0),
|
|
count: largeEmoji ? 5 : 8
|
|
)
|
|
}
|
|
|
|
private let sectionListSpace = "buzz.emoji.sectionList"
|
|
|
|
init(
|
|
data: NativeEmojiPickerData,
|
|
appearance: NativeEmojiPickerAppearance,
|
|
largeEmoji: Bool = false,
|
|
initialSkinTone: Int,
|
|
onSelect: @escaping (String) -> Void,
|
|
onSkinToneChanged: @escaping (Int) -> Void,
|
|
onClose: @escaping () -> Void
|
|
) {
|
|
self.data = data
|
|
self.appearance = appearance
|
|
self.largeEmoji = largeEmoji
|
|
self.onSelect = onSelect
|
|
self.onSkinToneChanged = onSkinToneChanged
|
|
self.onClose = onClose
|
|
_categorySelection = State(
|
|
initialValue: NativeEmojiCategorySelection(
|
|
initialSectionID: data.sections.first?.id
|
|
)
|
|
)
|
|
_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))
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 10) {
|
|
TablerNativeIcon(symbol: "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: {
|
|
TablerNativeIcon(symbol: "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) {
|
|
TablerNativeIcon(symbol: "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
|
|
NativeEmojiCategoryButton(
|
|
section: section,
|
|
appearance: appearance,
|
|
selection: categorySelection
|
|
) {
|
|
categorySelection.select(section.id)
|
|
// Category navigation is a frequent shortcut. Keeping it immediate
|
|
// means an in-progress proxy animation can never fight a finger drag.
|
|
proxy.scrollTo("section-\(section.id)", anchor: .top)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
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) {
|
|
TablerNativeIcon(symbol: "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)
|
|
.background(contentBoundaryReporter())
|
|
}
|
|
.coordinateSpace(name: sectionListSpace)
|
|
.background(viewportBoundaryReporter())
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
|
|
guard tracksSelection else { return }
|
|
categorySelection.select(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: data.sections.map(\.id),
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: offsets[nativeEmojiViewportBottomKey],
|
|
contentBottom: offsets[nativeEmojiContentBottomKey],
|
|
currentSelection: categorySelection.selectedSectionID
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func sectionOffsetReporter(id: String) -> some View {
|
|
GeometryReader { geometry in
|
|
Color.clear.preference(
|
|
key: NativeEmojiSectionOffsetsKey.self,
|
|
value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
onSelect(value)
|
|
} label: {
|
|
Group {
|
|
if let url = item.imageURL {
|
|
NativeEmojiRemoteImage(
|
|
url: url,
|
|
fallbackColor: appearance.secondaryText
|
|
)
|
|
.frame(width: largeEmoji ? 48 : 28, height: largeEmoji ? 48 : 28)
|
|
} else {
|
|
Text(value).font(.system(size: largeEmoji ? 48 : 28))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: largeEmoji ? 60 : 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]
|
|
}
|
|
}
|
|
|
|
/// Observes only the rail selection. Keeping this in a leaf view prevents a
|
|
/// scroll-frame highlight update from rebuilding the picker grid itself.
|
|
private struct NativeEmojiCategoryButton: View {
|
|
let section: NativeEmojiSection
|
|
let appearance: NativeEmojiPickerAppearance
|
|
@ObservedObject var selection: NativeEmojiCategorySelection
|
|
let onSelect: () -> Void
|
|
|
|
var body: some View {
|
|
let isSelected = selection.selectedSectionID == section.id
|
|
Button(action: onSelect) {
|
|
TablerNativeIcon(symbol: section.systemImage)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(
|
|
Color(
|
|
uiColor: isSelected
|
|
? appearance.accent : appearance.secondaryText
|
|
)
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 36)
|
|
.background(
|
|
isSelected ? Color(uiColor: appearance.control) : Color.clear,
|
|
in: Circle()
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(section.title)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
}
|
|
|
|
struct NativeEmojiRemoteImage: View {
|
|
let url: URL
|
|
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:
|
|
TablerNativeIcon(symbol: "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)
|
|
}
|
|
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<Void, Error>
|
|
}
|
|
|
|
private let maximumConcurrentDownloads: Int
|
|
private let downloader: Downloader
|
|
private let admissionAttemptForTesting: (() -> Void)?
|
|
private let cache = NSCache<NSURLRequest, UIImage>()
|
|
private var activeDownloadCount = 0
|
|
private var waiters: [Waiter] = []
|
|
|
|
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
|
|
}
|
|
|
|
func image(for request: URLRequest) async throws -> UIImage {
|
|
let cacheKey = request as NSURLRequest
|
|
if let cached = cache.object(forKey: cacheKey) {
|
|
return cached
|
|
}
|
|
|
|
recordAdmissionAttemptForTesting()
|
|
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 recordAdmissionAttemptForTesting() {
|
|
admissionAttemptForTesting?()
|
|
}
|
|
|
|
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<Void, Error>) 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,
|
|
delegate: NativeEmojiRedirectPolicy.shared
|
|
)
|
|
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
|
|
}
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
]
|
|
guard
|
|
let image = CGImageSourceCreateThumbnailAtIndex(
|
|
source,
|
|
0,
|
|
options as CFDictionary
|
|
)
|
|
else {
|
|
return nil
|
|
}
|
|
return UIImage(cgImage: image)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Mirrors the Dart hosted HTTP client, which disables redirects for requests
|
|
/// carrying the relay assertion: a redirect must not forward that bearer token
|
|
/// to another host. The 3xx then fails the load like any non-2xx response.
|
|
final class NativeEmojiRedirectPolicy: NSObject, URLSessionTaskDelegate {
|
|
static let shared = NativeEmojiRedirectPolicy()
|
|
static let relayAssertionHeader = "Nostr-Federated-Identity"
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
task: URLSessionTask,
|
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest request: URLRequest,
|
|
completionHandler: @escaping (URLRequest?) -> Void
|
|
) {
|
|
let carriesAssertion =
|
|
task.originalRequest?.value(forHTTPHeaderField: Self.relayAssertionHeader) != nil
|
|
completionHandler(carriesAssertion ? nil : request)
|
|
}
|
|
}
|
|
|
|
private struct TablerNativeIcon: View {
|
|
let symbol: String
|
|
var body: some View {
|
|
if let image = NativeTabler.image(symbol) {
|
|
Image(uiImage: image).renderingMode(.template)
|
|
}
|
|
}
|
|
}
|