You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Avatar.native.js 8.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. // @flow
  2. import React, { Component, Fragment, PureComponent } from 'react';
  3. import { Dimensions, Image, Platform, View } from 'react-native';
  4. import FastImage from 'react-native-fast-image';
  5. import { ColorPalette } from '../../styles';
  6. import styles from './styles';
  7. /**
  8. * The default image/source to be used in case none is specified or the
  9. * specified one fails to load.
  10. *
  11. * XXX The relative path to the default/stock (image) file is defined by the
  12. * {@code const} {@code DEFAULT_AVATAR_RELATIVE_PATH}. Unfortunately, the
  13. * packager of React Native cannot deal with it early enough for the following
  14. * {@code require} to succeed at runtime. Anyway, be sure to synchronize the
  15. * relative path on Web and mobile for the purposes of consistency.
  16. *
  17. * @private
  18. * @type {string}
  19. */
  20. const _DEFAULT_SOURCE = require('../../../../../images/avatar.png');
  21. /**
  22. * The type of the React {@link Component} props of {@link Avatar}.
  23. */
  24. type Props = {
  25. /**
  26. * The size for the {@link Avatar}.
  27. */
  28. size: number,
  29. /**
  30. * The URI of the {@link Avatar}.
  31. */
  32. uri: string
  33. };
  34. /**
  35. * The type of the React {@link Component} state of {@link Avatar}.
  36. */
  37. type State = {
  38. /**
  39. * Background color for the locally generated avatar.
  40. */
  41. backgroundColor: string,
  42. /**
  43. * Error indicator for non-local avatars.
  44. */
  45. error: boolean,
  46. /**
  47. * Indicates if the non-local avatar was loaded or not.
  48. */
  49. loaded: boolean,
  50. /**
  51. * Source for the non-local avatar.
  52. */
  53. source: { uri: ?string }
  54. };
  55. /**
  56. * Implements a React Native/mobile {@link Component} wich renders the content
  57. * of an Avatar.
  58. */
  59. class AvatarContent extends Component<Props, State> {
  60. /**
  61. * Initializes a new Avatar instance.
  62. *
  63. * @param {Props} props - The read-only React Component props with which
  64. * the new instance is to be initialized.
  65. */
  66. constructor(props: Props) {
  67. super(props);
  68. // Set the image source. The logic for the character # below is as
  69. // follows:
  70. // - Technically, URI is supposed to start with a scheme and scheme
  71. // cannot contain the character #.
  72. // - Technically, the character # in a URI signals the start of the
  73. // fragment/hash.
  74. // - Technically, the fragment/hash does not imply a retrieval
  75. // action.
  76. // - Practically, the fragment/hash does not always mandate a
  77. // retrieval action. For example, an HTML anchor with an href that
  78. // starts with the character # does not cause a Web browser to
  79. // initiate a retrieval action.
  80. // So I'll use the character # at the start of URI to not initiate
  81. // an image retrieval action.
  82. const source = {};
  83. if (props.uri && !props.uri.startsWith('#')) {
  84. source.uri = props.uri;
  85. }
  86. this.state = {
  87. backgroundColor: this._getBackgroundColor(props),
  88. error: false,
  89. loaded: false,
  90. source
  91. };
  92. // Bind event handlers so they are only bound once per instance.
  93. this._onAvatarLoaded = this._onAvatarLoaded.bind(this);
  94. this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
  95. }
  96. /**
  97. * Computes if the default avatar (ie, locally generated) should be used
  98. * or not.
  99. */
  100. get useDefaultAvatar() {
  101. const { error, loaded, source } = this.state;
  102. return !source.uri || error || !loaded;
  103. }
  104. /**
  105. * Computes a hash over the URI and returns a HSL background color. We use
  106. * 75% as lightness, for nice pastel style colors.
  107. *
  108. * @param {Object} props - The read-only React {@code Component} props from
  109. * which the background color is to be generated.
  110. * @private
  111. * @returns {string} - The HSL CSS property.
  112. */
  113. _getBackgroundColor({ uri }) {
  114. if (!uri) {
  115. return ColorPalette.white;
  116. }
  117. let hash = 0;
  118. /* eslint-disable no-bitwise */
  119. for (let i = 0; i < uri.length; i++) {
  120. hash = uri.charCodeAt(i) + ((hash << 5) - hash);
  121. hash |= 0; // Convert to 32-bit integer
  122. }
  123. /* eslint-enable no-bitwise */
  124. return `hsl(${hash % 360}, 100%, 75%)`;
  125. }
  126. /**
  127. * Helper which computes the style for the {@code Image} / {@code FastImage}
  128. * component.
  129. *
  130. * @private
  131. * @returns {Object}
  132. */
  133. _getImageStyle() {
  134. const { size } = this.props;
  135. return {
  136. ...styles.avatar,
  137. borderRadius: size / 2,
  138. height: size,
  139. width: size
  140. };
  141. }
  142. _onAvatarLoaded: () => void;
  143. /**
  144. * Handler called when the remote image loading finishes. This doesn't
  145. * necessarily mean the load was successful.
  146. *
  147. * @private
  148. * @returns {void}
  149. */
  150. _onAvatarLoaded() {
  151. this.setState({ loaded: true });
  152. }
  153. _onAvatarLoadError: () => void;
  154. /**
  155. * Handler called when the remote image loading failed.
  156. *
  157. * @private
  158. * @returns {void}
  159. */
  160. _onAvatarLoadError() {
  161. this.setState({ error: true });
  162. }
  163. /**
  164. * Renders a default, locally generated avatar image.
  165. *
  166. * @private
  167. * @returns {ReactElement}
  168. */
  169. _renderDefaultAvatar() {
  170. // When using a local image, react-native-fastimage falls back to a
  171. // regular Image, so we need to wrap it in a view to make it round.
  172. // https://github.com/facebook/react-native/issues/3198
  173. const { backgroundColor } = this.state;
  174. const imageStyle = this._getImageStyle();
  175. const viewStyle = {
  176. ...imageStyle,
  177. backgroundColor,
  178. // FIXME @lyubomir: Without the opacity below I feel like the
  179. // avatar colors are too strong. Besides, we use opacity for the
  180. // ToolbarButtons. That's where I copied the value from and we
  181. // may want to think about "standardizing" the opacity in the
  182. // app in a way similar to ColorPalette.
  183. opacity: 0.1,
  184. overflow: 'hidden'
  185. };
  186. return (
  187. <View style = { viewStyle }>
  188. <Image
  189. // The Image adds a fade effect without asking, so lets
  190. // explicitly disable it. More info here:
  191. // https://github.com/facebook/react-native/issues/10194
  192. fadeDuration = { 0 }
  193. resizeMode = 'contain'
  194. source = { _DEFAULT_SOURCE }
  195. style = { imageStyle } />
  196. </View>
  197. );
  198. }
  199. /**
  200. * Renders an avatar using a remote image.
  201. *
  202. * @private
  203. * @returns {ReactElement}
  204. */
  205. _renderAvatar() {
  206. const { source } = this.state;
  207. let extraStyle;
  208. if (this.useDefaultAvatar) {
  209. // On Android, the image loading indicators don't work unless the
  210. // Glide image is actually created, so we cannot use display: none.
  211. // Instead, render it off-screen, which does the trick.
  212. if (Platform.OS === 'android') {
  213. const windowDimensions = Dimensions.get('window');
  214. extraStyle = {
  215. bottom: -windowDimensions.height,
  216. right: -windowDimensions.width
  217. };
  218. } else {
  219. extraStyle = { display: 'none' };
  220. }
  221. }
  222. return (// $FlowFixMe
  223. <FastImage
  224. onError = { this._onAvatarLoadError }
  225. onLoadEnd = { this._onAvatarLoaded }
  226. resizeMode = 'contain'
  227. source = { source }
  228. style = { [ this._getImageStyle(), extraStyle ] } />
  229. );
  230. }
  231. /**
  232. * Implements React's {@link Component#render()}.
  233. *
  234. * @inheritdoc
  235. */
  236. render() {
  237. const { source } = this.state;
  238. return (
  239. <Fragment>
  240. { source.uri && this._renderAvatar() }
  241. { this.useDefaultAvatar && this._renderDefaultAvatar() }
  242. </Fragment>
  243. );
  244. }
  245. }
  246. /* eslint-disable react/no-multi-comp */
  247. /**
  248. * Implements an avatar as a React Native/mobile {@link Component}.
  249. *
  250. * Note: we use `key` in order to trigger a new component creation in case
  251. * the URI changes.
  252. */
  253. export default class Avatar extends PureComponent<Props> {
  254. /**
  255. * Implements React's {@link Component#render()}.
  256. *
  257. * @inheritdoc
  258. */
  259. render() {
  260. return (
  261. <AvatarContent
  262. key = { this.props.uri }
  263. { ...this.props } />
  264. );
  265. }
  266. }