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.7KB

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