Skip to content

Commit

Permalink
avatar: Add AvatarURL abstract class, and two subclasses.
Browse files Browse the repository at this point in the history
We'll soon start instantiating these from raw data at the edge, in
the "crunchy shell" pattern [1]. This will help us clean up lots of
confusing UI logic in the "soft center" of the app that's in charge
of displaying avatars, and we'll be able to uproot several helper
functions and simplify some props (often ambiguously `string`-typed)
of several React components.

This doesn't yet add support for the
`user_avatar_url_field_optional` client capability, but it will make
it much more straightforward to do so in an upcoming commit.

Some of the boilerplate in the subclasses is due to a performance
optimization [2] based on an observed ~1s added to the rehydrate
time on CZO when URL objects are constructed, vs. about 200ms (and
maybe a bit more in the upper tail) when they aren't. During
rehydration, we don't need to expensively construct URL objects to
validate raw URL strings, as long as we do so at the edge when we
receive them from the network.

See discussion [3].

[1] https://github.com/zulip/zulip-mobile/blob/master/docs/architecture/crunchy-shell.md
[2] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/user_avatar_url_field_optional/near/993660
[3] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/user_avatar_url_field_optional/near/908946
  • Loading branch information
chrisbobbe committed Nov 3, 2020
1 parent 75e0c4c commit a1f51e4
Show file tree
Hide file tree
Showing 2 changed files with 393 additions and 1 deletion.
152 changes: 151 additions & 1 deletion src/utils/__tests__/avatar-test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,155 @@
/* @flow strict-local */
import { getMediumAvatar, getGravatarFromEmail } from '../avatar';
import md5 from 'blueimp-md5';

import {
AvatarURL,
GravatarURL,
UploadedAvatarURL,
getMediumAvatar,
getGravatarFromEmail,
} from '../avatar';
import * as eg from '../../__tests__/lib/exampleData';

describe('AvatarURL', () => {
describe('fromUserOrBotData', () => {
const user = eg.makeUser();
const { email } = user;
const realm = eg.realm;

test('gives a `GravatarURL` if `rawAvatarURL` is null', () => {
const rawAvatarUrl = null;
expect(AvatarURL.fromUserOrBotData({ rawAvatarUrl, email, realm })).toBeInstanceOf(
GravatarURL,
);
});

test('gives a `GravatarURL` if `rawAvatarURL` is a URL string on Gravatar origin', () => {
const rawAvatarUrl =
'https://secure.gravatar.com/avatar/2efaec12efd9bea8a089299208117786?d=identicon&version=3';
expect(AvatarURL.fromUserOrBotData({ rawAvatarUrl, email, realm })).toBeInstanceOf(
GravatarURL,
);
});

test('gives an `UploadedAvatarURL` if `rawAvatarURL` is a non-Gravatar absolute URL string', () => {
const rawAvatarUrl =
'https://zulip-avatars.s3.amazonaws.com/13/430713047f2cffed661f84e139a64f864f17f286?x=x&version=5';
expect(AvatarURL.fromUserOrBotData({ rawAvatarUrl, email, realm })).toBeInstanceOf(
UploadedAvatarURL,
);
});

test('gives an `UploadedAvatarURL` if `rawAvatarURL` is a relative URL string', () => {
const rawAvatarUrl =
'/user_avatars/2/08fb6d007eb10a56efee1d64760fbeb6111c4352.png?x=x&version=2';
expect(AvatarURL.fromUserOrBotData({ rawAvatarUrl, email, realm })).toBeInstanceOf(
UploadedAvatarURL,
);
});
});
});

// Includes `undefined` for no size passed to AvatarURL.get()
const SIZES_WE_USE = [undefined, 24, 32, 80, 200];

describe('GravatarURL', () => {
test('serializes/deserializes correctly', () => {
const instance = GravatarURL.validateAndConstructInstance({ email: eg.selfUser.email });

const roundTripped = GravatarURL.deserialize(GravatarURL.serialize(instance));

SIZES_WE_USE.forEach(size => {
expect(instance.get(size).toString()).toEqual(roundTripped.get(size).toString());
});
});

test('lowercases email address before hashing', () => {
const email = 'uNuSuAlCaPs@example.com';
const instance = GravatarURL.validateAndConstructInstance({ email });
expect(instance.get().toString()).toContain(md5('unusualcaps@example.com'));
});

test('uses hash from server, if provided', () => {
const email = 'user13313@chat.zulip.org';
const hash = md5('cbobbe@zulip.com');
const instance = GravatarURL.validateAndConstructInstance({ email, hash });
expect(instance.get().toString()).toContain(hash);
});

test('produces corresponding URLs for all sizes', () => {
const instance = GravatarURL.validateAndConstructInstance({ email: eg.selfUser.email });

SIZES_WE_USE.filter(s => typeof s === 'number').forEach(size => {
if (size !== undefined) {
expect(instance.get(size).toString()).toContain(`s=${size.toString()}`);
} else {
expect(instance.get().toString()).not.toContain('s=');
}
});
});
});

describe('UploadedAvatarURL', () => {
test('serializes/deserializes correctly', () => {
const instance = UploadedAvatarURL.validateAndConstructInstance({
realm: eg.realm,
absoluteOrRelativeUrl:
'https://zulip-avatars.s3.amazonaws.com/13/430713047f2cffed661f84e139a64f864f17f286?x=x&version=5',
});

const roundTripped = UploadedAvatarURL.deserialize(UploadedAvatarURL.serialize(instance));

SIZES_WE_USE.forEach(size => {
expect(instance.get(size).toString()).toEqual(roundTripped.get(size).toString());
});
});

test('if a relative URL, gives a URL on the given realm', () => {
const instance = UploadedAvatarURL.validateAndConstructInstance({
realm: new URL('https://chat.zulip.org'),
absoluteOrRelativeUrl:
'/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2',
});
expect(instance.get().toString()).toEqual(
'https://chat.zulip.org/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2',
);
});

test('if an absolute URL, just use it', () => {
const instance = UploadedAvatarURL.validateAndConstructInstance({
realm: new URL('https://chat.zulip.org'),
absoluteOrRelativeUrl:
'https://zulip-avatars.s3.amazonaws.com/13/430713047f2cffed661f84e139a64f864f17f286?x=x&version=5',
});
expect(instance.get().toString()).toEqual(
'https://zulip-avatars.s3.amazonaws.com/13/430713047f2cffed661f84e139a64f864f17f286?x=x&version=5',
);
});

test('converts *.png to *-medium.png for sizes over 100', () => {
const realm = new URL('https://chat.zulip.org');
const instance = UploadedAvatarURL.validateAndConstructInstance({
realm,
absoluteOrRelativeUrl:
'/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2',
});
SIZES_WE_USE.forEach(size => {
if (size === undefined) {
expect(instance.get().toString()).toEqual(
'https://chat.zulip.org/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2',
);
} else if (size > 100) {
expect(instance.get(size).toString()).toEqual(
'https://chat.zulip.org/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae-medium.png?x=x&version=2',
);
} else {
expect(instance.get(size).toString()).toEqual(
'https://chat.zulip.org/user_avatars/2/e35cdbc4771c5e4b94e705bf6ff7cca7fa1efcae.png?x=x&version=2',
);
}
});
});
});

// avatarUrl can be converted to retrieve medium sized avatars(mediumAvatarUrl) if and only if
// avatarUrl contains avatar image name with a .png extension (e.g. AVATAR_IMAGE_NAME.png).
Expand Down
Loading

0 comments on commit a1f51e4

Please sign in to comment.