mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Harden native custom emoji loading
Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz> Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -8,8 +8,10 @@ Future<void> _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<void> _presentIosEmojiPicker({
|
||||
<String, Object>{
|
||||
'customEmoji': [
|
||||
for (final emoji in customEmoji)
|
||||
<String, String>{'shortcode': emoji.shortcode, 'url': emoji.url},
|
||||
<String, Object>{
|
||||
'shortcode': emoji.shortcode,
|
||||
'url': emoji.url,
|
||||
'headers': mediaAuth.headersFor(emoji.url),
|
||||
},
|
||||
],
|
||||
'recent': [for (final entry in recent) entry.emoji],
|
||||
'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
|
||||
|
||||
@@ -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<List<CustomEmoji>> palette;
|
||||
|
||||
@override
|
||||
Future<List<CustomEmoji>> 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 = <String>[];
|
||||
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': <String, String>{},
|
||||
},
|
||||
{
|
||||
'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<List<CustomEmoji>>();
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user