Skip to content

Commit 3072030

Browse files
committed
avatar: Fix avatar image blurriness
1 parent f3650ad commit 3072030

File tree

5 files changed

+122
-7
lines changed

5 files changed

+122
-7
lines changed

lib/model/avatar_url.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/// The size threshold above which is "medium" size for an avatar.
2+
///
3+
/// This is DEFAULT_AVATAR_SIZE in zerver/lib/upload.py.
4+
const defaultUploadSizePx = 100;
5+
6+
abstract class AvatarUrl {
7+
factory AvatarUrl.fromUserData({required Uri resolvedUrl}) {
8+
// TODO(#255): handle computing gravatars
9+
// TODO(#254): handle computing fallback avatar
10+
if (resolvedUrl.toString().startsWith(GravatarUrl.origin)) {
11+
return GravatarUrl(resolvedUrl: resolvedUrl);
12+
} else {
13+
return UploadedAvatarUrl(resolvedUrl: resolvedUrl);
14+
}
15+
}
16+
17+
Uri get(int sizePhysicalPx);
18+
}
19+
20+
class GravatarUrl implements AvatarUrl {
21+
GravatarUrl({required Uri resolvedUrl}) : standardUrl = resolvedUrl;
22+
23+
static String origin = 'https://secure.gravatar.com';
24+
25+
Uri standardUrl;
26+
27+
@override
28+
Uri get(int sizePhysicalPx) {
29+
return standardUrl.replace(queryParameters: {
30+
...standardUrl.queryParameters,
31+
's': sizePhysicalPx.toString(),
32+
});
33+
}
34+
}
35+
36+
class UploadedAvatarUrl implements AvatarUrl {
37+
UploadedAvatarUrl({required Uri resolvedUrl}) : standardUrl = resolvedUrl;
38+
39+
Uri standardUrl;
40+
41+
@override
42+
Uri get(int sizePhysicalPx) {
43+
if (sizePhysicalPx > defaultUploadSizePx) {
44+
return standardUrl.replace(
45+
path: standardUrl.path.replaceFirst(RegExp(r'(?:\.png)?$'), "-medium.png"));
46+
}
47+
48+
return standardUrl;
49+
}
50+
}

lib/widgets/content.dart

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
77

88
import '../api/core.dart';
99
import '../api/model/model.dart';
10+
import '../model/avatar_url.dart';
1011
import '../model/binding.dart';
1112
import '../model/content.dart';
1213
import '../model/internal_link.dart';
@@ -1059,9 +1060,19 @@ class AvatarImage extends StatelessWidget {
10591060
null => null, // TODO(#255): handle computing gravatars
10601061
var avatarUrl => store.tryResolveUrl(avatarUrl),
10611062
};
1062-
return (resolvedUrl == null)
1063-
? const SizedBox.shrink()
1064-
: RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium, fit: BoxFit.cover);
1063+
1064+
if (resolvedUrl == null) {
1065+
return const SizedBox.shrink();
1066+
}
1067+
1068+
final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl);
1069+
final physicalSize = ((MediaQuery.maybeDevicePixelRatioOf(context) ?? 1) * size).ceil();
1070+
1071+
return RealmContentNetworkImage(
1072+
avatarUrl.get(physicalSize),
1073+
filterQuality: FilterQuality.medium,
1074+
fit: BoxFit.cover,
1075+
);
10651076
}
10661077
}
10671078

lib/widgets/recent_dm_conversations.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,11 @@ class RecentDmConversationsItem extends StatelessWidget {
8181
required this.unreadCount,
8282
});
8383

84-
static const double _avatarSize = 32;
85-
8684
final DmNarrow narrow;
8785
final int unreadCount;
8886

87+
static const double _avatarSize = 32;
88+
8989
@override
9090
Widget build(BuildContext context) {
9191
final store = PerAccountStoreWidget.of(context);

test/model/avatar_url_test.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/model/avatar_url.dart';
4+
5+
void main() {
6+
const defaultSize = 30;
7+
const largeSize = 120;
8+
9+
group('GravatarUrl', () {
10+
test('gravatar url', () {
11+
final url = '${GravatarUrl.origin}/avatar/1234';
12+
final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
13+
14+
check(avatarUrl.get(defaultSize).toString()).equals('$url?s=30');
15+
});
16+
});
17+
18+
group('UploadedAvatarUrl', () {
19+
test('png image', () {
20+
const url = 'https://zulip.example/image.png';
21+
final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
22+
23+
check(avatarUrl.get(defaultSize).toString()).equals(url);
24+
});
25+
26+
test('png image, larger size', () {
27+
const url = 'https://zulip.example/image.png';
28+
final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
29+
final expectedUrl = url.replaceAll('.png', '-medium.png');
30+
31+
check(avatarUrl.get(largeSize).toString()).equals(expectedUrl);
32+
});
33+
});
34+
}

test/widgets/content_test.dart

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ void main() {
538538
group('AvatarImage', () {
539539
late PerAccountStore store;
540540

541-
Future<Uri?> actualUrl(WidgetTester tester, String avatarUrl) async {
541+
Future<Uri?> actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async {
542542
addTearDown(testBinding.reset);
543543
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
544544
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
@@ -548,7 +548,7 @@ void main() {
548548
prepareBoringImageHttpClient();
549549
await tester.pumpWidget(GlobalStoreWidget(
550550
child: PerAccountStoreWidget(accountId: eg.selfAccount.id,
551-
child: AvatarImage(userId: user.userId))));
551+
child: AvatarImage(userId: user.userId, size: size ?? 30))));
552552
await tester.pump();
553553
await tester.pump();
554554
tester.widget(find.byType(AvatarImage));
@@ -571,6 +571,26 @@ void main() {
571571
debugNetworkImageHttpClientProvider = null;
572572
});
573573

574+
testWidgets('absolute URL, larger size', (tester) async {
575+
tester.view.devicePixelRatio = 2.5;
576+
addTearDown(tester.view.resetDevicePixelRatio);
577+
578+
const avatarUrl = 'https://example/avatar.png';
579+
check(await actualUrl(tester, avatarUrl, 50)).isNotNull()
580+
.asString.equals(avatarUrl.replaceAll('.png', '-medium.png'));
581+
debugNetworkImageHttpClientProvider = null;
582+
});
583+
584+
testWidgets('relative URL, larger size', (tester) async {
585+
tester.view.devicePixelRatio = 2.5;
586+
addTearDown(tester.view.resetDevicePixelRatio);
587+
588+
const avatarUrl = '/avatar.png';
589+
check(await actualUrl(tester, avatarUrl, 50))
590+
.equals(store.tryResolveUrl('/avatar-medium.png')!);
591+
debugNetworkImageHttpClientProvider = null;
592+
});
593+
574594
testWidgets('smoke with invalid URL', (tester) async {
575595
const avatarUrl = '::not a URL::';
576596
check(await actualUrl(tester, avatarUrl)).isNull();

0 commit comments

Comments
 (0)