├── .eslintrc.json ├── .github └── workflows │ ├── build-demo.yaml │ ├── test-node.yaml │ └── tests.yaml ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── babel.config.js ├── demo ├── demo.css ├── favicon.png ├── index.html ├── index.js └── type-test.tsx ├── docs └── example1.jpg ├── index.d.ts ├── package.json ├── src ├── avatar.js ├── cache.js ├── components │ ├── image.js │ ├── text.js │ └── wrapper.js ├── context.js ├── data-provider.js ├── index.js ├── internal-state.js ├── sources │ ├── AvatarRedirect.js │ ├── Facebook.js │ ├── Github.js │ ├── Google.js │ ├── Gravatar.js │ ├── Icon.js │ ├── Instagram.js │ ├── Skype.js │ ├── Src.js │ ├── Twitter.js │ ├── VKontakte.js │ └── Value.js └── utils.js ├── tsconfig.json └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "babel-eslint", 4 | "plugins": [ "react" ], 5 | "env": { 6 | "browser": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "rules": { 10 | "no-console": 2, 11 | "no-debugger": 0, 12 | "no-shadow": 2, 13 | "no-use-before-define": 2, 14 | "eqeqeq": 0, 15 | "curly": 0, 16 | "no-underscore-dangle": 0, 17 | "quotes": [2, "single"], 18 | "semi": [ 2, "always" ], 19 | "space-unary-ops": 0, 20 | "space-infix-ops": 2, 21 | "indent": [ 2, 4 ], 22 | "strict": 0, 23 | "jsx-quotes": 1, 24 | "no-multi-spaces": 2, 25 | "no-trailing-spaces": 2, 26 | "spaced-comment": [2, "always", { "block": { "exceptions": ["*"] } } ], 27 | "no-lonely-if": 2, 28 | "no-negated-condition": 2, 29 | "no-multiple-empty-lines": 2, 30 | "react/display-name": 1, 31 | "new-parens": 2, 32 | "new-cap": 2, 33 | "eol-last": 2, 34 | "no-const-assign": 2, 35 | "consistent-this": [2, "self"], 36 | "linebreak-style": [2, "unix"], 37 | "max-nested-callbacks": [2, 3], 38 | "no-class-assign": 2, 39 | "no-dupe-class-members": 2, 40 | "no-this-before-super": 2, 41 | "prefer-const": 1, 42 | "react/jsx-boolean-value": [2, "always"], 43 | "react/jsx-closing-bracket-location": [2, { "location": "after-props" }], 44 | "react/jsx-curly-spacing": 2, 45 | "react/jsx-indent-props": [2, 4], 46 | "react/jsx-max-props-per-line": [2, { "maximum": 4}], 47 | "react/jsx-no-duplicate-props": 2, 48 | "react/jsx-no-undef": 2, 49 | "react/jsx-uses-react": 2, 50 | "react/jsx-uses-vars": 2, 51 | "react/no-did-mount-set-state": 2, 52 | "react/no-did-update-set-state": 2, 53 | "react/no-unknown-property": 2, 54 | "react/prop-types": 2, 55 | "react/self-closing-comp": 2, 56 | "react/sort-comp": 2, 57 | "react/jsx-wrap-multilines": 2 58 | }, 59 | "settings": { 60 | "react": { 61 | "version": "detect" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/build-demo.yaml: -------------------------------------------------------------------------------- 1 | name: 'Generate demo' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | node: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup node 16 | uses: actions/setup-node@v3 17 | 18 | - name: Install 19 | run: npm install 20 | 21 | - name: Run tests 22 | run: npm run build:demo 23 | 24 | - name: Publish to GitHub pages 25 | if: success() 26 | uses: crazy-max/ghaction-github-pages@v3 27 | with: 28 | target_branch: gh-pages 29 | build_dir: build 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/test-node.yaml: -------------------------------------------------------------------------------- 1 | name: Test a node version 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | node: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | node: 12 | concurrency: ${{ github.ref }}${{ inputs.node }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ inputs.node }} 22 | 23 | - name: Install 24 | run: npm install 25 | 26 | - name: Run tests 27 | run: npm test 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: 'Run tests' 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | node-latest: 10 | uses: ./.github/workflows/test-node.yaml 11 | with: 12 | node: latest 13 | 14 | node-18: 15 | uses: ./.github/workflows/test-node.yaml 16 | with: 17 | node: 18 18 | 19 | node-20: 20 | uses: ./.github/workflows/test-node.yaml 21 | with: 22 | node: 20 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build 3 | lib 4 | es 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /build 3 | /demo 4 | /docs 5 | /src 6 | /*.config.js 7 | /.* 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Wim Mostmans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <Avatar> [![Build Status](https://travis-ci.org/Sitebase/react-avatar.svg?branch=master)](https://travis-ci.org/Sitebase/react-avatar) [![npm downloads](https://img.shields.io/npm/dm/react-avatar.svg)](https://www.npmjs.com/package/react-avatar) [![version](https://img.shields.io/npm/v/react-avatar.svg)](https://www.npmjs.com/package/react-avatar) ![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/react-avatar.svg) ![npm type definitions](https://img.shields.io/npm/types/react-avatar.svg) 2 | 3 | Universal avatar makes it possible to fetch/generate an avatar based on the information you have about that user. 4 | We use a fallback system that if for example an invalid Facebook ID is used it will try Google, and so on. 5 | 6 | ![React Avatar component preview](docs/example1.jpg) 7 | 8 | For the moment we support following types: 9 | 10 | * Facebook 11 | * GitHub 12 | * Google (using [Avatar Redirect](#avatar-redirect)) 13 | * Twitter (using [Avatar Redirect](#avatar-redirect)) 14 | * Instagram (using [Avatar Redirect](#avatar-redirect)) 15 | * Vkontakte (using [Avatar Redirect](#avatar-redirect)) 16 | * Skype 17 | * Gravatar 18 | * Custom image 19 | * Name initials 20 | 21 | The fallbacks are in the same order as the list above were Facebook has the highest priority. 22 | 23 | ## Demo 24 | 25 | [Check it live!](https://ambassify.github.io/react-avatar/) 26 | 27 | ## Install 28 | 29 | Install the component using [NPM](https://www.npmjs.com/): 30 | 31 | ```sh 32 | $ npm install react-avatar --save 33 | 34 | # besides React, react-avatar also has prop-types as peer dependency, 35 | # make sure to install it into your project 36 | $ npm install prop-types --save 37 | ``` 38 | 39 | Or [download as ZIP](https://github.com/sitebase/react-avatar/archive/master.zip). 40 | 41 | #### Note on usage in Gatsby projects 42 | 43 | Users of **Gatsby** who are experiencing issues with the latest release should install `react-avatar@corejs2` instead. This is an older version (v3.7.0) release of `react-avatar` that still used `core-js@2`. 44 | 45 | If you'd like to use the latest version of `react-avatar` have a look at [#187](https://github.com/Sitebase/react-avatar/issues/187) for a workaround and [#187](https://github.com/Sitebase/react-avatar/issues/187#issuecomment-587187113), [#181](https://github.com/Sitebase/react-avatar/issues/181) and [#198](https://github.com/Sitebase/react-avatar/issues/198) for a description of the issue. 46 | 47 | 48 | ## Usage 49 | 50 | 1. Import Custom Element: 51 | 52 | ```js 53 | import Avatar from 'react-avatar'; 54 | ``` 55 | 56 | 2. Start using it! 57 | 58 | ```html 59 | 60 | ``` 61 | 62 | **Some examples:** 63 | 64 | ```html 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ``` 77 | 78 | **Manually generating a color:** 79 | 80 | ```html 81 | import Avatar from 'react-avatar'; 82 | 83 | 84 | ``` 85 | 86 | **Configuring React Avatar globally** 87 | 88 | ```html 89 | import Avatar, { ConfigProvider } from 'react-avatar'; 90 | 91 | 92 | 93 | ... 94 | 95 | ... 96 | 97 | 98 | 99 | ``` 100 | 101 | ## Options 102 | 103 | ### Avatar 104 | 105 | | Attribute | Options | Default | Description | 106 | | ------------- | ----------------- | ------- | ------------------------------------------------------------------------------------------------------ | 107 | | `className` | *string* | | Name of the CSS class you want to add to this component alongside the default `sb-avatar`. | 108 | | `email` | *string* | | String of the email address of the user. | 109 | | `md5Email` | *string* | | String of the MD5 hash of email address of the user. | 110 | | `facebookId` | *string* | | | 111 | | `twitterHandle` | *string* | | | 112 | | `instagramId` | *string* | | | 113 | | `googleId` | *string* | | | 114 | | `githubHandle`| *string* | | String of the user's GitHub handle. | 115 | | `skypeId` | *string* | | | 116 | | `name` | *string* | | Will be used to generate avatar based on the initials of the person | 117 | | `maxInitials` | *number* | | Set max nr of characters used for the initials. If maxInitials=2 and the name is Foo Bar Var the initials will be FB | 118 | | `initials` | *string or function* | [defaultInitials][3] | Set the initials to show or a function that derives them from the component props, the method should have the signature `fn(name, props)` | 119 | | `value` | *string* | | Show a value as avatar | 120 | | `alt` | *string* | `name` or `value` | The `alt` attribute used on the avatar `img` tag. If not set we will fallback to either `name` or `value` | 121 | | `title` | *string* | `name` or `value` | The `title` attribute used on the avatar `img` tag. If not set we will fallback to either `name` or `value` | 122 | | `color` | *string* | random | Used in combination with `name` and `value`. Give the background a fixed color with a hex like for example #FF0000 | 123 | | `fgColor` | *string* | #FFF | Used in combination with `name` and `value`. Give the text a fixed color with a hex like for example #FF0000 | 124 | | `size` | *[length][1]* | 50px | Size of the avatar | 125 | | `textSizeRatio` | *number* | 3 | For text based avatars the size of the text as a fragment of size (size / textSizeRatio) | 126 | | `textMarginRatio` | *number* | .15 | For text based avatars. The size of the minimum margin between the text and the avatar's edge, used to make sure the text will always fit inside the avatar. (calculated as `size * textMarginRatio`) | 127 | | `round` | *bool or [length][1]* | false | The amount of `border-radius` to apply to the avatar corners, `true` shows the avatar in a circle. | 128 | | `src` | *string* | | Fallback image to use | 129 | | `style` | *object* | | Style that will be applied on the root element | 130 | | `unstyled` | *bool* | false | Disable all styles | 131 | | `onClick` | *function* | | Mouse click event | 132 | 133 | ### ConfigProvider 134 | 135 | | Attribute | Options | Default | Description | 136 | | ------------- | ----------------- | ------- | ------------------------------------------------------------------------------------------------------ | 137 | | `colors` | *array(string)* | [default colors](https://github.com/Sitebase/react-avatar/tree/master/src/utils.js#L39-L47) | A list of color values as strings from which the `getRandomColor` picks one at random. | 138 | | `cache` | *[cache](#implementing-a-custom-cache)* | [internal cache](https://github.com/Sitebase/react-avatar/tree/master/src/cache.js) | Cache implementation used to track broken img URLs | 139 | | `initials` | *function* | [defaultInitials][3] | A function that derives the initials from the component props, the method should have the signature `fn(name, props)` | 140 | | `avatarRedirectUrl` | *URL* | `undefined` | Base URL to a [Avatar Redirect](#avatar-redirect) instance | 141 | 142 | **Example** 143 | 144 | ```html 145 | import Avatar, { ConfigProvider } from 'react-avatar'; 146 | 147 | 148 | 149 | ... 150 | 151 | ... 152 | 153 | 154 | 155 | ``` 156 | 157 | ### Cache 158 | 159 | This class represents the default implementation of the cache used by react-avatar. 160 | 161 | Looking to implement more complex [custom cache behaviour](#implementing-a-custom-cache)? 162 | 163 | | Attribute | Options | Default | Description | 164 | | ------------- | ----------------- | ------- | ------------------------------------------------------------------------------------------------------ | 165 | | `cachePrefix` | *string* | `react-avatar/` | The prefix for `localStorage` keys used by the cache. | 166 | | `sourceTTL` | *number* | 604800000 (7 days) | The amount of time a failed source is kept in cache. (in milliseconds) | 167 | | `sourceSize` | *number* | 20 | The maximum number of failed source entries to keep in cache at any time. | 168 | 169 | **usage** 170 | 171 | ```html 172 | import Avatar, { Cache, ConfigProvider } from 'react-avatar'; 173 | 174 | const cache = new Cache({ 175 | 176 | // Keep cached source failures for up to 7 days 177 | sourceTTL: 7 * 24 * 3600 * 1000, 178 | 179 | // Keep a maximum of 20 entries in the source cache 180 | sourceSize: 20 181 | }); 182 | 183 | // Apply cache globally 184 | 185 | 186 | ... 187 | 188 | ... 189 | 190 | 191 | 192 | // For specific instances 193 | 194 | 195 | ``` 196 | 197 | ### Avatar Redirect 198 | 199 | [Avatar Redirect][2] adds support for social networks which require a server-side service to find the correct avatar URL. 200 | 201 | Examples of this are: 202 | 203 | - Twitter 204 | - Instagram 205 | 206 | An open Avatar Redirect endpoint is provided at `https://avatar-redirect.appspot.com`. However this endpoint is provided for free and as such an explicit opt-in is required as no guarantees can be made about uptime of this endpoint. 207 | 208 | Avatar Redirect is enabled by setting the `avatarRedirectUrl` property on the [ConfigProvider context](#configprovider) 209 | 210 | ## Development 211 | 212 | In order to run it locally you'll need to fetch some dependencies and a basic server setup. 213 | 214 | * Install local dependencies: 215 | 216 | ```sh 217 | $ npm install 218 | ``` 219 | 220 | * To test your react-avatar and your changes, start the development server and open `http://localhost:8000/index.html`. 221 | 222 | ```sh 223 | $ npm run dev 224 | ``` 225 | 226 | * To create a local production build into the `lib` and `es` folders. 227 | 228 | ```sh 229 | $ npm run build 230 | ``` 231 | 232 | ### Implementing a custom cache 233 | 234 | `cache` as provided to the `ConfigProvider` should be an object implementing the methods below. The default cache implementation can be found [here](https://github.com/Sitebase/react-avatar/tree/master/src/cache.js) 235 | 236 | | Method | Description | 237 | | ------------- | ------------------------------------------------------------------------------------------------------ | 238 | | `set(key, value)` | Save `value` at `key`, such that it can be retrieved using `get(key)`. Returns `undefined` | 239 | | `get(key)` | Retrieve the value stored at `key`, if the cache does not contain a value for `key` return `null` | 240 | | `sourceFailed(source)` | Mark the image URL specified in `source` as failed. Returns `undefined` | 241 | | `hasSourceFailedBefore(source)` | Returns `true` if the `source` has been tagged as failed using `sourceFailed(source)`, otherwise `false`. | 242 | 243 | ## Reducing bundle size 244 | 245 | ### Webpack 4 246 | 247 | When using webpack 4 you can rely on [tree shaking](https://webpack.js.org/guides/tree-shaking/) to drop unused sources when creating your Avatar component like the example below. 248 | 249 | ```javascript 250 | import { createAvatarComponent, TwitterSource } from 'react-avatar'; 251 | 252 | const Avatar = createAvatarComponent({ 253 | sources: [ TwitterSource ] 254 | }); 255 | ``` 256 | 257 | Exported sources: 258 | - GravatarSource 259 | - FacebookSource 260 | - GithubSource 261 | - SkypeSource 262 | - ValueSource 263 | - SrcSource 264 | - IconSource 265 | - VKontakteSource 266 | - InstagramSource 267 | - TwitterSource 268 | - GoogleSource 269 | - RedirectSource 270 | 271 | ### Without Webpack >= 4 272 | 273 | If you are using a version of webpack that does not support tree shaking or are using a different bundler you'll need to import only those files you need. 274 | 275 | #### ES6 modules 276 | ```javascript 277 | import createAvatarComponent from 'react-avatar/es/avatar'; 278 | import TwitterSource from 'react-avatar/es/sources/Twitter'; 279 | 280 | const Avatar = createAvatarComponent({ 281 | sources: [ TwitterSource ] 282 | }); 283 | ``` 284 | 285 | #### Transpiled ES5 javascript / commonjs 286 | ```javascript 287 | const createAvatarComponent = require('react-avatar/lib/avatar').default; 288 | const TwitterSource = require('react-avatar/lib/sources/Twitter').default; 289 | 290 | const Avatar = createAvatarComponent({ 291 | sources: [ TwitterSource ] 292 | }); 293 | ``` 294 | 295 | ## Products using React Avatar 296 | 297 | * [Ambassify](https://www.ambassify.com/?utm_source=github&utm_medium=readme&utm_campaign=react-avatar) 298 | 299 | ## Contributing 300 | 301 | 1. Fork it! 302 | 2. Create your feature branch: `git checkout -b my-new-feature` 303 | 3. Commit your changes: `git commit -m 'Add some feature'` 304 | 4. Push to the branch: `git push origin my-new-feature` 305 | 5. Submit a pull request :D 306 | 307 | ## History 308 | 309 | For detailed changelog, check [Releases](https://github.com/sitebase/react-avatar/releases). 310 | 311 | ## Maintainers 312 | 313 | - [@Sitebase](https://github.com/Sitebase) (Creator) 314 | - [@JorgenEvens](https://github.com/JorgenEvens) 315 | 316 | ## License 317 | 318 | [MIT License](http://opensource.org/licenses/MIT) 319 | 320 | [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/length 321 | [2]: https://github.com/JorgenEvens/avatar-redirect 322 | [3]: https://github.com/Sitebase/react-avatar/blob/master/src/utils.js 323 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const { BABEL_MODULES = false } = process.env; 4 | 5 | module.exports = { 6 | targets: '> 0.25%, not dead', 7 | presets: [ 8 | [ '@babel/preset-env', { modules: BABEL_MODULES } ], 9 | '@babel/preset-react' 10 | ], 11 | plugins: [ 12 | '@babel/plugin-proposal-class-properties', 13 | '@babel/plugin-transform-runtime', 14 | [ 'polyfill-corejs3', { method: 'usage-pure' } ], 15 | ] 16 | }; 17 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | body { 6 | line-height: 1.7em; 7 | color: #7f8c8d; 8 | font-size: 14px; 9 | font-family: 'Roboto Slab', Arial, sans-serif; 10 | text-align:center; 11 | } 12 | a:link, a:active, a:visited { 13 | color: #fff; 14 | text-decoration: underline; 15 | } 16 | a:hover { 17 | text-decoration: none; 18 | } 19 | p { 20 | padding: 5px 0; 21 | } 22 | 23 | h1 { 24 | font-size: 70px; 25 | font-weight: 100; 26 | line-height: 1em; 27 | margin-bottom: 30px; 28 | } 29 | h2 { 30 | padding: 10px 0 20px 0; 31 | } 32 | #header { 33 | background: #7e3794; 34 | padding: 50px 20px; 35 | color: #FFF; 36 | margin-bottom: 30px; 37 | } 38 | #container { 39 | } 40 | section { 41 | margin: 0 0 30px 0; 42 | } 43 | .sb-avatar { 44 | margin: 5px; 45 | } 46 | button { 47 | border: none; 48 | background: #7e3794; 49 | margin: 5px; 50 | color: #fff; 51 | text-transform: uppercase; 52 | font-size: 14px; 53 | padding: 15px; 54 | border-radius: 3px; 55 | } 56 | .hidden { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /demo/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambassify/react-avatar/1a22019f32f7f636ed52f982363618d7dae79b69/demo/favicon.png -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Avatar Component 6 | 7 | 8 | 9 | 11 | 12 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import Avatar, { 6 | getRandomColor, 7 | ConfigProvider, 8 | createAvatarComponent, 9 | GravatarSource, 10 | ValueSource 11 | } from './../src'; 12 | 13 | const CustomAvatar = createAvatarComponent({ 14 | sources: [ GravatarSource, ValueSource ] 15 | }); 16 | 17 | import './index.html'; 18 | import './demo.css'; 19 | import './favicon.png'; 20 | 21 | const customColors = [ 22 | '#5E005E', 23 | '#AB2F52', 24 | '#E55D4A', 25 | '#E88554', 26 | '#4194A6', 27 | '#82CCD9', 28 | '#FFCC6B', 29 | '#F2855C', 30 | '#7D323B' 31 | ]; 32 | 33 | export default 34 | class Demo extends React.Component { 35 | static displayName = 'Demo'; 36 | 37 | state = { 38 | name: 'Wim Mostmans', 39 | skypeId: null, 40 | toggle: true, 41 | color: customColors[0] 42 | } 43 | 44 | _onToggleName = () => { 45 | this.setState({ 46 | name: 'Foo Bar', 47 | skypeId: null 48 | }); 49 | } 50 | 51 | _onChangeName = (e) => { 52 | this.setState({ 53 | name: e.target.value 54 | }); 55 | } 56 | 57 | _onSetSkype = () => { 58 | this.setState({skypeId: 'sitebase'}); 59 | } 60 | 61 | _onClick = () => { 62 | alert('Clicked!'); 63 | } 64 | 65 | _onToggle = () => { 66 | this.setState({ 67 | toggle: !this.state.toggle 68 | }); 69 | } 70 | 71 | _onToggleColor = () => { 72 | const current = customColors.indexOf(this.state.color); 73 | const next = (current + 1) % customColors.length; 74 | 75 | this.setState({ color: customColors[next] }); 76 | } 77 | 78 | _onAttachRef(ref) { 79 | // Dummy function to test errors on reference 80 | if(console) { 81 | // eslint-disable-next-line no-console 82 | console.log('Ref received', ref); 83 | } 84 | } 85 | 86 | render() { 87 | return ( 88 |
89 |
90 |

Configure

91 |

Set a name to use in the examples below.

92 | 98 |
99 |
100 |

Gravatar

101 | 102 | 103 | 104 | 105 |
106 | 107 |
108 |

Invalid gravatar

109 | 110 | 111 | 112 | 113 |
114 | 115 |
116 |

Initials Text Size

117 | 118 | 119 | 120 | 121 |
122 | 123 | 124 |
125 |

Google

126 | 127 | 128 | 129 | 130 |
131 |
132 | 133 |
134 |

Facebook

135 | 136 | 137 | 138 | 139 |
140 | 141 | 142 |
143 |

Twitter using Avatar Redirect

144 | 145 | 146 | 147 | 148 |
149 |
150 | 151 | 152 |
153 |

Instagram using Avatar Redirect

154 | 155 | 156 | 157 | 158 |
159 |
160 | 161 | 162 |
163 |

Vkontakte

164 | 165 | 166 | 167 | 168 |
169 |
170 | 171 |
172 |

Skype

173 | 174 | 175 | 176 | 177 |
178 | 179 |
180 |

Github

181 | 182 | 183 | 184 | 185 |
186 | 187 |
188 |

Initials

189 |
190 | 191 | 192 |
193 | 194 | 195 | 196 | 197 |
198 | 199 |
200 |

onClick

201 | 202 |
203 | 204 |
205 |

Initials with different font sizes

206 |
207 | 208 | 209 | 210 | 211 |
212 |
213 | 214 | 215 | 216 | 217 |
218 |
219 | 220 |
221 |

Size in different units

222 |
223 | 224 | 225 | 226 | 227 |
228 |
229 | 230 | 231 | 232 | 233 |
234 |
235 |
236 | 237 |
238 |
239 | 240 |
241 |
242 | 243 |
244 |
245 | 246 |
247 |
248 |
249 | 250 | 251 | 252 | 253 | 254 |
255 |
256 | 257 |
258 |

Initials are always restrained to a margin

259 |
260 | 261 | 262 | 263 | 264 |
265 |
266 | 267 | 268 | 269 | 270 |
271 |
272 | 273 | 274 | 275 | 276 |
277 |
278 |
279 | 280 |
281 |
282 | 283 |
284 |
285 | 286 |
287 |
288 | 289 |
290 |
291 |
292 | 293 |
294 |

Custom colors

295 |
296 | 297 | 298 | 299 | 300 |
301 |
302 | 303 |
304 |

Nullable title

305 |
306 | 310 | 315 | 320 | 325 |
326 |
327 | 328 |
329 |

Initials with maximum number of characters

330 |
331 | 333 | 335 | 336 | 337 |
338 |
339 | 340 |
341 |

Value

342 | 343 | 344 | 345 | 346 |
347 | 348 |
349 |

Fallback to static src

350 | 351 |
352 | 353 |
354 |

Double fallback: Facebook to Google to initials

355 | 360 |
361 |
362 |

Custom style

363 | 367 |
368 |
369 |

Unstyled

370 | 373 |
374 |
375 |

Vertical Alignment

376 | 377 | Wim Mostmans 378 | 379 | Wim Mostmans 380 | 381 | Wim Mostmans 382 | 383 | Wim Mostmans 384 |
385 |
386 |

Toggle with cached Avatars

387 |
388 | 389 |
390 | {this.state.toggle && 391 |
392 | 393 | 394 | 395 | 396 |
397 | } 398 |
399 |
400 |

Toggle color

401 |
402 | 403 |
404 |
405 | {this.state.color} 406 |
407 |
408 | 409 | 410 | 411 | 412 |
413 |
414 | 415 | 416 |
417 |

Configuration Context

418 |
419 | 420 | 421 | 422 | 423 |
424 |
425 |
426 | 427 | name.split(/\s+/)[0]}> 428 |
429 |

Custom Initials Function

430 |
431 | 432 | 433 | 434 | 435 |
436 |
437 |
438 | 439 |
440 |

Avatar with only support for gravatar and value

441 | 442 | 443 | 444 |
445 | 446 |
447 | ); 448 | } 449 | } 450 | 451 | var mountNode = document.getElementById('container'); 452 | 453 | // Enable strict mode when supported by react version 454 | var Wrapper = React.StrictMode || 'div'; 455 | 456 | ReactDOM.render(( 457 | 458 | 459 | 460 | ), mountNode); 461 | -------------------------------------------------------------------------------- /demo/type-test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains a very small demo project 3 | * that is tested for type correctness in the 4 | * `npm run test:type-check` check. 5 | * 6 | * This file is intended to include any API interface 7 | * to ensure that our type definitions are correct. 8 | */ 9 | 10 | import React, { Component } from 'react'; 11 | import Avatar, { 12 | createAvatarComponent, 13 | ConfigProvider, 14 | Cache, 15 | 16 | GravatarSource, 17 | FacebookSource, 18 | GithubSource, 19 | SkypeSource, 20 | ValueSource, 21 | SrcSource, 22 | IconSource, 23 | 24 | VKontakteSource, 25 | InstagramSource, 26 | TwitterSource, 27 | GoogleSource, 28 | } from '..'; 29 | 30 | const CustomAvatar = createAvatarComponent({ 31 | sources: [ 32 | GravatarSource, 33 | FacebookSource, 34 | GithubSource, 35 | SkypeSource, 36 | ValueSource, 37 | SrcSource, 38 | IconSource, 39 | VKontakteSource, 40 | InstagramSource, 41 | TwitterSource, 42 | GoogleSource, 43 | ] 44 | }); 45 | 46 | export default 47 | class TypeTest extends Component { 48 | 49 | render() { 50 | return ( 51 |
52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/example1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambassify/react-avatar/1a22019f32f7f636ed52f982363618d7dae79b69/docs/example1.jpg -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface ReactAvatarProps { 4 | children?: React.ReactNode; 5 | /** 6 | * Name of the CSS class you want to add to this component alongside the default sb-avatar. 7 | */ 8 | className?: string; 9 | /** 10 | * String of the email address of the user. 11 | */ 12 | email?: string; 13 | /** 14 | * String of the MD5 hash of email address of the user. 15 | */ 16 | md5Email?: string; 17 | facebookId?: string; 18 | twitterHandle?: string; 19 | googleId?: string; 20 | instagramId?: string; 21 | githubHandle?: string; 22 | skypeId?: string; 23 | /** 24 | * Will be used to generate avatar based on the initials of the person 25 | */ 26 | name?: string; 27 | /** 28 | * Set max nr of characters used for the initials. If maxInitials=2 and the name is Foo Bar Var the initials will be FB 29 | */ 30 | maxInitials?: number; 31 | /** 32 | * Initials to show or a method converting name into initials 33 | * @param {string} name 34 | * @param {any} value 35 | * @returns {string} 36 | */ 37 | initials?: string | ((name: string, props: any) => string); 38 | /** 39 | * Show a value as avatar 40 | */ 41 | value?: string; 42 | /** 43 | * The alt attribute used on the avatar img tag. If not set we will fallback to either name or value 44 | */ 45 | alt?: string | boolean; 46 | /** 47 | * The title attribute used on the avatar img tag. If not set we will fallback to either name or value 48 | */ 49 | title?: string | boolean; 50 | /** 51 | * Used in combination with `name` and `value`. Give the background a fixed color with a hex like for example #FF0000 52 | */ 53 | color?: string; 54 | /** 55 | * Used in combination with `name` and `value`. Give the text a fixed color with a hex like for example #FF0000 56 | */ 57 | fgColor?: string; 58 | /** 59 | * Size of the avatar 60 | */ 61 | size?: string; 62 | /** 63 | * For text based avatars the size of the text as a fragment of size (size / textSizeRatio) 64 | */ 65 | textSizeRatio?: number; 66 | /** 67 | * For text based avatars. The size of the minimum margin between the text and the avatar's edge, used to make sure the text will always fit inside the avatar. (calculated as `size * textMarginRatio`) 68 | */ 69 | textMarginRatio?: number; 70 | /** 71 | * The amount of `border-radius` to apply to the avatar corners, `true` shows the avatar in a circle. 72 | */ 73 | round?: boolean | string; 74 | /** 75 | * Fallback image to use 76 | */ 77 | src?: string; 78 | /** 79 | * Style that will be applied on the root element 80 | */ 81 | style?: any; 82 | /** 83 | * Disable all styles 84 | */ 85 | unstyled?: boolean; 86 | /** 87 | * Mouse click event 88 | * @param {React.SyntheticEvent} e 89 | * @returns {any} 90 | */ 91 | onClick?: (e: React.SyntheticEvent) => any; 92 | } 93 | 94 | interface CreateAvatarOptions { 95 | sources?: SourceConstructor[] 96 | } 97 | 98 | export interface ConfigProvider { 99 | /** 100 | * A list of color values as strings from which the getRandomColor picks one at random. 101 | */ 102 | colors?: string[]; 103 | /** 104 | * Cache implementation used to track broken img URLs 105 | */ 106 | cache?: Cache; 107 | /** 108 | * Method converting name into initials 109 | * @param {string} name 110 | * @param {any} value 111 | * @returns {string} 112 | */ 113 | initials?: (name: string, props: any) => string; 114 | /** 115 | * The baseUrl for a avatar-redirect service 116 | */ 117 | avatarRedirectUrl?: string; 118 | } 119 | 120 | export interface Cache { 121 | /** 122 | * Save `value` at `key`, such that it can be retrieved using `get(key)` 123 | * @param {string} key 124 | * @param {string} value 125 | */ 126 | set: (key: string, value: string) => void; 127 | /** 128 | * Retrieve the value stored at `key`, if the cache does not contain a value for `key` return `null` 129 | * @param {string} key 130 | * @returns {string | null} 131 | */ 132 | get: (key: string) => string | null; 133 | /** 134 | * Mark the image URL specified in `source` as failed. 135 | * @param {string} source 136 | */ 137 | sourceFailed: (source: string) => void; 138 | /** 139 | * Returns `true` if the `source` has been tagged as failed using `sourceFailed(source)`, otherwise `false`. 140 | * @param {string} source 141 | * @returns {boolean} 142 | */ 143 | hasSourceFailedBefore: (source: string) => boolean; 144 | } 145 | 146 | export interface CacheOptions { 147 | cachePrefix?: string, 148 | sourceTTL?: number, 149 | sourceSize?: number 150 | } 151 | 152 | type CacheConstructor = new (options: CacheOptions) => Cache; 153 | 154 | interface Source { 155 | isCompatible: () => boolean; 156 | get: (setState: (update: object) => void) => void; 157 | } 158 | 159 | type SourceConstructor = new (props: object) => Source; 160 | 161 | export const RedirectSource: (network: string, property: string) => SourceConstructor 162 | 163 | /** 164 | * Universal avatar makes it possible to fetch/generate an avatar based on the information you have about that user. 165 | * We use a fallback system that if for example an invalid Facebook ID is used it will try Google, and so on. 166 | */ 167 | type ReactAvatar = React.ComponentType; 168 | declare const Avatar : ReactAvatar; 169 | 170 | export const createAvatarComponent: (options: CreateAvatarOptions) => ReactAvatar; 171 | 172 | export const ConfigProvider: React.ComponentType; 173 | export const Cache: CacheConstructor; 174 | 175 | export const GravatarSource: SourceConstructor; 176 | export const FacebookSource: SourceConstructor; 177 | export const GithubSource: SourceConstructor; 178 | export const SkypeSource: SourceConstructor; 179 | export const ValueSource: SourceConstructor; 180 | export const SrcSource: SourceConstructor; 181 | export const IconSource: SourceConstructor; 182 | 183 | export const VKontakteSource: SourceConstructor; 184 | export const InstagramSource: SourceConstructor; 185 | export const TwitterSource: SourceConstructor; 186 | export const GoogleSource: SourceConstructor; 187 | 188 | export default Avatar; 189 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-avatar", 3 | "version": "5.0.3", 4 | "description": "Universal React avatar component makes it possible to generate avatars based on user information.", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "types": "index.d.ts", 8 | "typings": "index.d.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "prepack": "npm -s run test && npm -s run build", 12 | "clean": "rm -rf ./{lib,es}/*", 13 | "build": "npm -s run clean && npm -s run build:commonjs && npm -s run build:modules", 14 | "build:commonjs": "BABEL_MODULES=commonjs babel ./src --out-dir ./lib", 15 | "build:modules": "babel ./src --out-dir ./es", 16 | "build:demo": "NODE_ENV=production webpack", 17 | "demo": "npm -s run build:demo", 18 | "dev": "webpack-dev-server", 19 | "serve": "npm -s run dev", 20 | "test": "npm -s run test:lint && npm -s run test:type-check", 21 | "test:lint": "eslint src", 22 | "test:type-check": "tsc -p tsconfig.json", 23 | "postpublish": "npm -s run publish:docs", 24 | "publish:docs": "npm -s run build:demo && gh-pages -d build" 25 | }, 26 | "license": "MIT", 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/ambassify/react-avatar.git" 30 | }, 31 | "author": "Wim Mostmans ", 32 | "bugs": { 33 | "url": "https://github.com/ambassify/react-avatar/issues" 34 | }, 35 | "keywords": [ 36 | "component", 37 | "reactjs", 38 | "react-component", 39 | "avatar" 40 | ], 41 | "homepage": "https://ambassify.github.io/react-avatar/", 42 | "peerDependencies": { 43 | "@babel/runtime": ">=7", 44 | "core-js-pure": ">=3", 45 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", 46 | "prop-types": "^15.0.0 || ^16.0.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.7.7", 50 | "@babel/core": "^7.7.7", 51 | "@babel/plugin-proposal-class-properties": "^7.7.4", 52 | "@babel/plugin-transform-runtime": "^7.16.0", 53 | "@babel/preset-env": "^7.7.7", 54 | "@babel/preset-react": "^7.7.4", 55 | "@babel/runtime": "^7.16.0", 56 | "@types/react": "^16.0.0", 57 | "babel-eslint": "^10.0.3", 58 | "babel-loader": "^8.0.6", 59 | "babel-plugin-polyfill-corejs3": "^0.3.0", 60 | "core-js-pure": "^3.19.1", 61 | "eslint": "^6.8.0", 62 | "eslint-loader": "^3.0.3", 63 | "eslint-plugin-react": "^7.17.0", 64 | "file-loader": "^5.0.2", 65 | "gh-pages": "^6.1.1", 66 | "react": "^17.0.1", 67 | "react-dom": "^17.0.1", 68 | "typescript": "^5.4.5", 69 | "webpack": "^5.72.1", 70 | "webpack-bundle-analyzer": "^3.6.0", 71 | "webpack-cli": "^4.9.2", 72 | "webpack-dev-server": "^4.9.1" 73 | }, 74 | "dependencies": { 75 | "is-retina": "^1.0.3", 76 | "md5": "^2.0.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/avatar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | import { Cache } from './cache'; 6 | import {withConfig, ConfigProvider} from './context'; 7 | import createAvatarDataProvider from './data-provider'; 8 | import {getRandomColor} from './utils'; 9 | 10 | import Image from './components/image'; 11 | import Text from './components/text'; 12 | 13 | export {getRandomColor} from './utils'; 14 | export {ConfigProvider} from './context'; 15 | export {Cache} from './cache'; 16 | 17 | export default 18 | function createAvatarComponent(options) { 19 | 20 | const DataProvider = createAvatarDataProvider(options); 21 | 22 | const Component = withConfig( 23 | // eslint-disable-next-line react/display-name 24 | React.forwardRef((props, ref) => ( 25 | 26 | {avatar => { 27 | const Avatar = avatar.src ? Image : Text; 28 | 29 | return ( 30 | 33 | ); 34 | }} 35 | 36 | )) 37 | ); 38 | 39 | return Object.assign(Component, { 40 | getRandomColor, 41 | ConfigProvider, 42 | Cache 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | export const CACHE_PREFIX = 'react-avatar/'; 2 | export const CACHE_KEY_FAILING = 'failing'; 3 | 4 | const _hasLocalStorage = (function isLocalStorageAvailable() { 5 | try { 6 | return ('localStorage' in window && window['localStorage']); 7 | } catch(err) { 8 | return false; 9 | } 10 | }()); 11 | 12 | export 13 | class Cache { 14 | 15 | constructor(options = {}) { 16 | const { 17 | cachePrefix = CACHE_PREFIX, 18 | sourceTTL = 7 * 24 * 3600 * 1000, 19 | sourceSize = 20 20 | } = options; 21 | 22 | this.cachePrefix = cachePrefix; 23 | this.sourceTTL = sourceTTL; 24 | this.sourceSize = sourceSize; 25 | } 26 | 27 | set(key, value) { 28 | // cache not available 29 | if (!_hasLocalStorage) 30 | return; 31 | 32 | value = JSON.stringify(value); 33 | 34 | try { 35 | localStorage.setItem(this.cachePrefix + key, value); 36 | } catch(e) { 37 | // failsafe for mobile Safari private mode 38 | console.error(e); // eslint-disable-line no-console 39 | } 40 | } 41 | 42 | get(key) { 43 | // cache not available 44 | if (!_hasLocalStorage) 45 | return null; 46 | 47 | const value = localStorage.getItem(this.cachePrefix + key); 48 | 49 | if (value) 50 | return JSON.parse(value); 51 | 52 | return null; 53 | } 54 | 55 | sourceFailed(source) { 56 | let cacheList = this.get(CACHE_KEY_FAILING) || []; 57 | 58 | // Remove expired entries or previous instances of this source 59 | cacheList = cacheList.filter(entry => { 60 | const hasExpired = entry.expires > 0 && entry.expires < Date.now(); 61 | const isMatch = entry === source || entry.url == source; 62 | 63 | return !hasExpired && !isMatch; 64 | }); 65 | 66 | // Add the source to the end of the list 67 | cacheList.unshift({ 68 | url: source, 69 | expires: Date.now() + this.sourceTTL 70 | }); 71 | 72 | // only keep the last X results so we don't fill up local storage 73 | cacheList = cacheList.slice(0, this.sourceSize - 1); 74 | 75 | return this.set(CACHE_KEY_FAILING, cacheList); 76 | } 77 | 78 | hasSourceFailedBefore(source) { 79 | const cacheList = this.get(CACHE_KEY_FAILING) || []; 80 | 81 | return cacheList.some(entry => { 82 | const hasExpired = entry.expires > 0 && entry.expires < Date.now(); 83 | const isMatch = entry === source || entry.url == source; 84 | 85 | return isMatch && !hasExpired; 86 | }); 87 | } 88 | } 89 | 90 | export default new Cache(); 91 | -------------------------------------------------------------------------------- /src/components/image.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { parseSize, calculateBorderRadius, getNullableText } from '../utils'; 5 | import Wrapper from './wrapper'; 6 | 7 | export default 8 | class AvatarImage extends React.PureComponent { 9 | 10 | static propTypes = { 11 | alt: PropTypes.oneOfType([ 12 | PropTypes.string, 13 | PropTypes.bool 14 | ]), 15 | title: PropTypes.oneOfType([ 16 | PropTypes.string, 17 | PropTypes.bool 18 | ]), 19 | name: PropTypes.string, 20 | value: PropTypes.string, 21 | avatar: PropTypes.object, 22 | 23 | className: PropTypes.string, 24 | unstyled: PropTypes.bool, 25 | round: PropTypes.oneOfType([ 26 | PropTypes.bool, 27 | PropTypes.string, 28 | PropTypes.number, 29 | ]), 30 | size: PropTypes.oneOfType([ 31 | PropTypes.number, 32 | PropTypes.string 33 | ]), 34 | } 35 | 36 | static defaultProps = { 37 | className: '', 38 | round: false, 39 | size: 100, 40 | unstyled: false 41 | } 42 | 43 | render() { 44 | const { 45 | className, 46 | round, 47 | unstyled, 48 | alt, 49 | title, 50 | name, 51 | value, 52 | avatar 53 | } = this.props; 54 | 55 | const size = parseSize(this.props.size); 56 | 57 | const imageStyle = unstyled ? null : { 58 | maxWidth: '100%', 59 | width: size.str, 60 | height: size.str, 61 | borderRadius: calculateBorderRadius(round), 62 | }; 63 | 64 | return ( 65 | 66 | {getNullableText(alt, 74 | 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/components/text.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Wrapper from './wrapper'; 5 | import { 6 | parseSize, 7 | setGroupedTimeout, 8 | calculateBorderRadius, 9 | getNullableText 10 | } from '../utils'; 11 | 12 | export default 13 | class AvatarText extends React.PureComponent { 14 | 15 | static propTypes = { 16 | name: PropTypes.string, 17 | value: PropTypes.string, 18 | avatar: PropTypes.object, 19 | 20 | title: PropTypes.oneOfType([ 21 | PropTypes.string, 22 | PropTypes.bool 23 | ]), 24 | 25 | className: PropTypes.string, 26 | unstyled: PropTypes.bool, 27 | fgColor: PropTypes.string, 28 | textSizeRatio: PropTypes.number, 29 | textMarginRatio: PropTypes.number, 30 | round: PropTypes.oneOfType([ 31 | PropTypes.bool, 32 | PropTypes.string, 33 | PropTypes.number, 34 | ]), 35 | size: PropTypes.oneOfType([ 36 | PropTypes.number, 37 | PropTypes.string 38 | ]), 39 | } 40 | 41 | static defaultProps = { 42 | className: '', 43 | fgColor: '#FFF', 44 | round: false, 45 | size: 100, 46 | textSizeRatio: 3, 47 | textMarginRatio: .15, 48 | unstyled: false 49 | } 50 | 51 | componentDidMount() { 52 | this._mounted = true; 53 | this._scaleTextNode(this._node); 54 | } 55 | 56 | componentWillUnmount() { 57 | this._mounted = false; 58 | } 59 | 60 | _scaleTextNode = (node, retryTTL = 16) => { 61 | const { 62 | unstyled, 63 | textSizeRatio, 64 | textMarginRatio, 65 | avatar 66 | } = this.props; 67 | 68 | this._node = node; 69 | 70 | if (!node || !node.parentNode || unstyled || avatar.src || !this._mounted) 71 | return; 72 | 73 | const spanNode = node.parentNode; 74 | const tableNode = spanNode.parentNode; 75 | 76 | const { 77 | width: containerWidth, 78 | height: containerHeight 79 | } = spanNode.getBoundingClientRect(); 80 | 81 | // Whenever the avatar element is not visible due to some CSS 82 | // (such as display: none) on any parent component we will check 83 | // whether the component has become visible. 84 | // 85 | // The time between checks grows up to half a second in an attempt 86 | // to reduce flicker / performance issues. 87 | if (containerWidth == 0 && containerHeight == 0) { 88 | const ttl = Math.min(retryTTL * 1.5, 500); 89 | setGroupedTimeout(() => this._scaleTextNode(node, ttl), ttl); 90 | return; 91 | } 92 | 93 | // If the tableNode (outer-container) does not have its fontSize set yet, 94 | // we'll set it according to "textSizeRatio" 95 | if (!tableNode.style.fontSize) { 96 | const baseFontSize = containerHeight / textSizeRatio; 97 | tableNode.style.fontSize = `${baseFontSize}px`; 98 | } 99 | 100 | // Reset font-size such that scaling works correctly (#133) 101 | spanNode.style.fontSize = null; 102 | 103 | // Measure the actual width of the text after setting the container size 104 | const { width: textWidth } = node.getBoundingClientRect(); 105 | 106 | if (textWidth < 0) 107 | return; 108 | 109 | // Calculate the maximum width for the text based on "textMarginRatio" 110 | const maxTextWidth = containerWidth * (1 - (2 * textMarginRatio)); 111 | 112 | // If the text is too wide, scale it down by (maxWidth / actualWidth) 113 | if (textWidth > maxTextWidth) 114 | spanNode.style.fontSize = `calc(1em * ${maxTextWidth / textWidth})`; 115 | } 116 | 117 | render() { 118 | const { 119 | className, 120 | round, 121 | unstyled, 122 | title, 123 | name, 124 | value, 125 | avatar 126 | } = this.props; 127 | 128 | const size = parseSize(this.props.size); 129 | 130 | const initialsStyle = unstyled ? null : { 131 | width: size.str, 132 | height: size.str, 133 | lineHeight: 'initial', 134 | textAlign: 'center', 135 | color: this.props.fgColor, 136 | background: avatar.color, 137 | borderRadius: calculateBorderRadius(round), 138 | }; 139 | 140 | const tableStyle = unstyled ? null : { 141 | display: 'table', 142 | tableLayout: 'fixed', 143 | width: '100%', 144 | height: '100%' 145 | }; 146 | 147 | const spanStyle = unstyled ? null : { 148 | display: 'table-cell', 149 | verticalAlign: 'middle', 150 | fontSize: '100%', 151 | whiteSpace: 'nowrap' 152 | }; 153 | 154 | // Ensure the text node is updated and scaled when any of these 155 | // values changed by calling the `_scaleTextNode` method using 156 | // the correct `ref`. 157 | const key = [ 158 | avatar.value, 159 | this.props.size 160 | ].join(''); 161 | 162 | return ( 163 | 164 |
167 |
168 | 169 | 170 | {avatar.value} 171 | 172 | 173 |
174 |
175 |
176 | ); 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /src/components/wrapper.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { parseSize, calculateBorderRadius } from '../utils'; 5 | 6 | export default 7 | class AvatarWrapper extends React.PureComponent { 8 | 9 | static propTypes = { 10 | className: PropTypes.string, 11 | round: PropTypes.oneOfType([ 12 | PropTypes.bool, 13 | PropTypes.string 14 | ]), 15 | style: PropTypes.object, 16 | size: PropTypes.oneOfType([ 17 | PropTypes.number, 18 | PropTypes.string 19 | ]), 20 | unstyled: PropTypes.bool, 21 | avatar: PropTypes.object, 22 | 23 | onClick: PropTypes.func, 24 | children: PropTypes.node, 25 | } 26 | 27 | render() { 28 | const { 29 | className, 30 | unstyled, 31 | round, 32 | style, 33 | avatar, 34 | onClick, 35 | children, 36 | 37 | } = this.props; 38 | const { sourceName } = avatar; 39 | const size = parseSize(this.props.size); 40 | 41 | const hostStyle = unstyled ? null : { 42 | display: 'inline-block', 43 | verticalAlign: 'middle', 44 | width: size.str, 45 | height: size.str, 46 | borderRadius: calculateBorderRadius(round), 47 | fontFamily: 'Helvetica, Arial, sans-serif', 48 | ...style 49 | }; 50 | 51 | const classNames = [ className, 'sb-avatar' ]; 52 | 53 | if (sourceName) { 54 | const source = sourceName.toLowerCase() 55 | .replace(/[^a-z0-9-]+/g, '-') // only allow alphanumeric 56 | .replace(/^-+|-+$/g, ''); // trim `-` 57 | classNames.push('sb-avatar--' + source); 58 | } 59 | 60 | return ( 61 |
64 | {children} 65 |
66 | ); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import defaultCache from './cache'; 5 | import {defaultColors, defaultInitials} from './utils'; 6 | 7 | const defaults = { 8 | cache: defaultCache, 9 | colors: defaultColors, 10 | initials: defaultInitials, 11 | avatarRedirectUrl: null 12 | }; 13 | 14 | const contextKeys = Object.keys(defaults); 15 | 16 | /** 17 | * withConfig and ConfigProvider provide a compatibility layer for different 18 | * versions of React equiped with different versions of the Context API. 19 | * 20 | * If the new Context API is available it will be used, otherwise we will 21 | * fall back to the legacy context api. 22 | */ 23 | 24 | const ConfigContext = React.createContext && React.createContext(); 25 | const isLegacyContext = !ConfigContext; 26 | const ConfigConsumer = isLegacyContext ? null : ConfigContext.Consumer; 27 | 28 | /** 29 | * This was introduced in React 16.3.0 we need this to 30 | * prevent errors in newer versions. But we will just forward the 31 | * component for any version lower than 16.3.0 32 | * 33 | * https://github.com/Sitebase/react-avatar/issues/201 34 | * https://github.com/facebook/react/blob/master/CHANGELOG.md#1630-march-29-2018 35 | */ 36 | const forwardRef = React.forwardRef || (C => C); 37 | 38 | export class ConfigProvider extends React.Component { 39 | 40 | static displayName = 'ConfigProvider'; 41 | 42 | static propTypes = { 43 | cache: PropTypes.object, 44 | colors: PropTypes.arrayOf(PropTypes.string), 45 | initials: PropTypes.func, 46 | avatarRedirectUrl: PropTypes.string, 47 | 48 | children: PropTypes.node 49 | } 50 | 51 | _getContext() { 52 | const context = {}; 53 | 54 | contextKeys.forEach(key => { 55 | if (typeof this.props[key] !== 'undefined') 56 | context[key] = this.props[key]; 57 | }); 58 | 59 | return context; 60 | } 61 | 62 | render() { 63 | const { children } = this.props; 64 | 65 | if (isLegacyContext) 66 | return React.Children.only(children); 67 | 68 | return ( 69 | 70 | {React.Children.only(children)} 71 | 72 | ); 73 | } 74 | 75 | } 76 | 77 | export const withConfig = (Component) => { 78 | function withAvatarConfig(props, refOrContext) { 79 | 80 | // If legacy context is enabled, there is no support for forwardedRefs either 81 | if (isLegacyContext) { 82 | const ctx = refOrContext && refOrContext.reactAvatar; 83 | return ( ); 84 | } 85 | 86 | /* eslint-disable react/display-name */ 87 | return ( 88 | 89 | {config => ( 90 | 94 | )} 95 | 96 | ); 97 | /* eslint-enable react/display-name */ 98 | } 99 | 100 | // Legacy support, only set when legacy is detected 101 | withAvatarConfig.contextTypes = ConfigProvider.childContextTypes; 102 | 103 | return forwardRef(withAvatarConfig); 104 | }; 105 | 106 | if (isLegacyContext) { 107 | ConfigProvider.childContextTypes = { reactAvatar: PropTypes.object }; 108 | ConfigProvider.prototype.getChildContext = function() { 109 | return { reactAvatar: this._getContext() }; 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/data-provider.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { PureComponent } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import { Cache } from './cache'; 7 | import {withConfig, ConfigProvider} from './context'; 8 | import InternalState from './internal-state'; 9 | 10 | export {getRandomColor} from './utils'; 11 | export {ConfigProvider} from './context'; 12 | export {Cache} from './cache'; 13 | 14 | function matchSource(Source, props, cb) { 15 | const { cache } = props; 16 | const instance = new Source(props); 17 | 18 | if(!instance.isCompatible(props)) 19 | return cb(); 20 | 21 | instance.get((state) => { 22 | const failedBefore = state && state.src && 23 | cache.hasSourceFailedBefore(state.src); 24 | 25 | if(!failedBefore && state) { 26 | cb(state); 27 | } else { 28 | cb(); 29 | } 30 | }); 31 | } 32 | 33 | export default 34 | function createAvatarDataProvider({ sources = [] }) { 35 | 36 | // Collect propTypes for each individual source 37 | const sourcePropTypes = sources.reduce((r, s) => Object.assign(r, s.propTypes), {}); 38 | 39 | class AvatarDataProvider extends PureComponent { 40 | 41 | static displayName = 'AvatarDataProvider' 42 | 43 | static propTypes = { 44 | // PropTypes defined on sources 45 | ...sourcePropTypes, 46 | 47 | cache: PropTypes.object, 48 | propertyName: PropTypes.string, 49 | } 50 | 51 | static defaultProps = { 52 | propertyName: 'avatar', 53 | } 54 | 55 | constructor(props) { 56 | super(props); 57 | 58 | this.state = { 59 | internal: null, 60 | src: null, 61 | value: null, 62 | color: props.color, 63 | }; 64 | } 65 | 66 | componentDidMount() { 67 | this.fetch(); 68 | } 69 | 70 | componentDidUpdate(prevProps) { 71 | let needsUpdate = false; 72 | 73 | // This seems redundant 74 | // 75 | // Props that need to be in `state` are 76 | // `value`, `src` and `color` 77 | for (const prop in sourcePropTypes) 78 | needsUpdate = needsUpdate || (prevProps[prop] !== this.props[prop]); 79 | 80 | if (needsUpdate) 81 | setTimeout(this.fetch, 0); 82 | } 83 | 84 | componentWillUnmount() { 85 | if (this.state.internal) { 86 | this.state.internal.active = false; 87 | } 88 | } 89 | 90 | static Cache = Cache; 91 | static ConfigProvider = ConfigProvider 92 | 93 | _createFetcher = (internal) => (errEvent) => { 94 | const { cache } = this.props; 95 | 96 | if (!internal.isActive(this.state)) 97 | return; 98 | 99 | // Mark img source as failed for future reference 100 | if( errEvent && errEvent.type === 'error' ) 101 | cache.sourceFailed(errEvent.target.src); 102 | 103 | const pointer = internal.sourcePointer; 104 | if(sources.length === pointer) 105 | return; 106 | 107 | const source = sources[pointer]; 108 | 109 | internal.sourcePointer++; 110 | 111 | matchSource(source, this.props, (nextState) => { 112 | if (!nextState) 113 | return setTimeout(internal.fetch, 0); 114 | 115 | if (!internal.isActive(this.state)) 116 | return; 117 | 118 | // Reset other values to prevent them from sticking (#51) 119 | nextState = { 120 | src: null, 121 | value: null, 122 | color: null, 123 | 124 | ...nextState 125 | }; 126 | 127 | this.setState(state => { 128 | // Internal state has been reset => we received new props 129 | return internal.isActive(state) ? nextState : {}; 130 | }); 131 | }); 132 | } 133 | 134 | fetch = () => { 135 | const internal = new InternalState(); 136 | internal.fetch = this._createFetcher(internal); 137 | 138 | this.setState({ internal }, internal.fetch); 139 | }; 140 | 141 | render() { 142 | const { children, propertyName } = this.props; 143 | const { src, value, color, sourceName, internal } = this.state; 144 | 145 | const avatarData = { 146 | src, 147 | value, 148 | color, 149 | sourceName, 150 | onRenderFailed: () => internal && internal.fetch() // eslint-disable-line 151 | }; 152 | 153 | if (typeof children === 'function') 154 | return children(avatarData); 155 | 156 | const child = React.Children.only(children); 157 | return React.cloneElement(child, { 158 | [propertyName]: avatarData, 159 | }); 160 | } 161 | } 162 | 163 | return Object.assign(withConfig(AvatarDataProvider), { 164 | ConfigProvider, 165 | Cache 166 | }); 167 | } 168 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import createAvatarComponent from './avatar'; 4 | import gravatarSource from './sources/Gravatar'; 5 | import facebookSource from './sources/Facebook'; 6 | import githubSource from './sources/Github'; 7 | import skypeSource from './sources/Skype'; 8 | import valueSource from './sources/Value'; 9 | import srcSource from './sources/Src'; 10 | import iconSource from './sources/Icon'; 11 | 12 | // Avatar Redirect 13 | import twitterSource from './sources/Twitter'; 14 | import vkontakteSource from './sources/VKontakte'; 15 | import instagramSource from './sources/Instagram'; 16 | import googleSource from './sources/Google'; 17 | 18 | const SOURCES = [ 19 | facebookSource, 20 | googleSource, 21 | githubSource, 22 | twitterSource, 23 | instagramSource, 24 | vkontakteSource, 25 | skypeSource, 26 | gravatarSource, 27 | srcSource, 28 | valueSource, 29 | iconSource 30 | ]; 31 | 32 | export * from './avatar'; 33 | export { default as createAvatarComponent } from './avatar'; 34 | export { default as createAvatarDataProvider } from './data-provider'; 35 | 36 | export default createAvatarComponent({ 37 | sources: SOURCES 38 | }); 39 | 40 | export { default as GravatarSource } from './sources/Gravatar'; 41 | export { default as FacebookSource } from './sources/Facebook'; 42 | export { default as GithubSource } from './sources/Github'; 43 | export { default as SkypeSource } from './sources/Skype'; 44 | export { default as ValueSource } from './sources/Value'; 45 | export { default as SrcSource } from './sources/Src'; 46 | export { default as IconSource } from './sources/Icon'; 47 | 48 | // Avatar Redirect 49 | export { default as VKontakteSource } from './sources/VKontakte'; 50 | export { default as InstagramSource } from './sources/Instagram'; 51 | export { default as TwitterSource } from './sources/Twitter'; 52 | export { default as GoogleSource } from './sources/Google'; 53 | export { default as RedirectSource } from './sources/AvatarRedirect'; 54 | -------------------------------------------------------------------------------- /src/internal-state.js: -------------------------------------------------------------------------------- 1 | export default 2 | class InternalState { 3 | 4 | constructor() { 5 | this.sourcePointer = 0; 6 | this.active = true; 7 | this.fetch = null; 8 | } 9 | 10 | isActive(state = {}) { 11 | // Internal state has been reset => we received new props 12 | if (state.internal !== this) 13 | return false; 14 | 15 | if (!this.fetch) 16 | return false; 17 | 18 | if (this.active !== true) 19 | return false; 20 | 21 | return true; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/sources/AvatarRedirect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import PropTypes from 'prop-types'; 3 | import { getImageSize } from '../utils'; 4 | 5 | export default 6 | function createRedirectSource(network, property) { 7 | return class AvatarRedirectSource { 8 | 9 | static propTypes = { 10 | [property]: PropTypes.oneOfType([ 11 | PropTypes.string, 12 | PropTypes.number 13 | ]) 14 | } 15 | 16 | props = null; 17 | 18 | constructor(props) { 19 | this.props = props; 20 | } 21 | 22 | isCompatible = () => { 23 | return !!this.props.avatarRedirectUrl && !!this.props[property]; 24 | } 25 | 26 | get = (setState) => { 27 | const { avatarRedirectUrl } = this.props; 28 | const size = getImageSize(this.props.size); 29 | 30 | const baseUrl = avatarRedirectUrl.replace(/\/*$/, '/'); 31 | const id = this.props[property]; 32 | 33 | const query = size ? `size=${size}` : ''; 34 | const src = `${baseUrl}${network}/${id}?${query}`; 35 | 36 | setState({ sourceName: network, src }); 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/sources/Facebook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import PropTypes from 'prop-types'; 3 | import { getImageSize } from '../utils'; 4 | 5 | export default 6 | class FacebookSource { 7 | 8 | static propTypes = { 9 | facebookId: PropTypes.string 10 | } 11 | 12 | props = null; 13 | 14 | constructor(props) { 15 | this.props = props; 16 | } 17 | 18 | isCompatible = () => !!this.props.facebookId 19 | 20 | get = (setState) => { 21 | const { facebookId } = this.props; 22 | const size = getImageSize(this.props.size); 23 | 24 | let url = `https://graph.facebook.com/${facebookId}/picture`; 25 | 26 | if (size) 27 | url += `?width=${size}&height=${size}`; 28 | 29 | setState({ 30 | sourceName: 'facebook', 31 | src: url 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/sources/Github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import PropTypes from 'prop-types'; 3 | import { getImageSize } from '../utils'; 4 | 5 | export default 6 | class GithubSource { 7 | 8 | static propTypes = { 9 | githubHandle: PropTypes.string 10 | } 11 | 12 | props = null; 13 | 14 | constructor(props) { 15 | this.props = props; 16 | } 17 | 18 | isCompatible = () => !!this.props.githubHandle 19 | 20 | get = (setState) => { 21 | const { githubHandle } = this.props; 22 | const size = getImageSize(this.props.size); 23 | 24 | let url = `https://avatars.githubusercontent.com/${githubHandle}?v=4`; 25 | 26 | if (size) 27 | url += `&s=${size}`; 28 | 29 | setState({ 30 | sourceName: 'github', 31 | src: url 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/sources/Google.js: -------------------------------------------------------------------------------- 1 | import redirectSource from './AvatarRedirect'; 2 | 3 | export default redirectSource('google', 'googleId'); 4 | -------------------------------------------------------------------------------- /src/sources/Gravatar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import md5 from 'md5'; 5 | 6 | import { getImageSize } from '../utils'; 7 | 8 | 9 | export default 10 | class GravatarSource { 11 | 12 | static propTypes = { 13 | email: PropTypes.string, 14 | md5Email: PropTypes.string 15 | } 16 | 17 | props = null; 18 | 19 | constructor(props) { 20 | this.props = props; 21 | } 22 | 23 | isCompatible = () => { 24 | return !!this.props.email || !!this.props.md5Email; 25 | } 26 | 27 | get = (setState) => { 28 | const { props } = this; 29 | const email = props.md5Email || md5(props.email); 30 | const size = getImageSize(props.size); 31 | 32 | let url = `https://secure.gravatar.com/avatar/${email}?d=404`; 33 | 34 | if (size) 35 | url += `&s=${size}`; 36 | 37 | setState({ 38 | sourceName: 'gravatar', 39 | src: url 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/sources/Icon.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import {getRandomColor} from '../utils'; 5 | 6 | export default 7 | class IconSource { 8 | 9 | props = null 10 | icon = '✷' 11 | 12 | static propTypes = { 13 | color: PropTypes.string 14 | } 15 | 16 | constructor(props) { 17 | this.props = props; 18 | } 19 | 20 | isCompatible = () => true 21 | 22 | get = (setState) => { 23 | const { color, colors } = this.props; 24 | setState({ 25 | sourceName: 'icon', 26 | value: this.icon, 27 | color: color || getRandomColor(this.icon, colors) 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/sources/Instagram.js: -------------------------------------------------------------------------------- 1 | import redirectSource from './AvatarRedirect'; 2 | 3 | export default redirectSource('instagram', 'instagramId'); 4 | -------------------------------------------------------------------------------- /src/sources/Skype.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import PropTypes from 'prop-types'; 4 | 5 | export default 6 | class SkypeSource { 7 | 8 | static propTypes = { 9 | skypeId: PropTypes.string 10 | } 11 | 12 | props = null; 13 | 14 | constructor(props) { 15 | this.props = props; 16 | } 17 | 18 | isCompatible = () => !!this.props.skypeId 19 | 20 | get = (setState) => { 21 | const { skypeId } = this.props; 22 | const url = `https://api.skype.com/users/${skypeId}/profile/avatar`; 23 | 24 | setState({ 25 | sourceName: 'skype', 26 | src: url 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/sources/Src.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import PropTypes from 'prop-types'; 4 | 5 | export default 6 | class SrcSource { 7 | 8 | static propTypes = { 9 | src: PropTypes.string 10 | } 11 | 12 | props = null 13 | 14 | constructor(props) { 15 | this.props = props; 16 | } 17 | 18 | isCompatible = () => !!this.props.src; 19 | 20 | get = (setState) => { 21 | setState({ 22 | sourceName: 'src', 23 | src: this.props.src 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/sources/Twitter.js: -------------------------------------------------------------------------------- 1 | import redirectSource from './AvatarRedirect'; 2 | 3 | export default redirectSource('twitter', 'twitterHandle'); 4 | -------------------------------------------------------------------------------- /src/sources/VKontakte.js: -------------------------------------------------------------------------------- 1 | import redirectSource from './AvatarRedirect'; 2 | 3 | export default redirectSource('vkontakte', 'vkontakteId'); 4 | -------------------------------------------------------------------------------- /src/sources/Value.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import {getRandomColor, defaultInitials} from '../utils'; 5 | 6 | export default 7 | class ValueSource { 8 | 9 | static propTypes = { 10 | color: PropTypes.string, 11 | name: PropTypes.string, 12 | value: PropTypes.string, 13 | email: PropTypes.string, 14 | maxInitials: PropTypes.number, 15 | initials: PropTypes.oneOfType([ 16 | PropTypes.string, 17 | PropTypes.func 18 | ]) 19 | } 20 | 21 | props = null 22 | 23 | constructor(props) { 24 | this.props = props; 25 | } 26 | 27 | isCompatible = () => { 28 | return !!(this.props.name || this.props.value || this.props.email); 29 | } 30 | 31 | getInitials() { 32 | const { name, initials } = this.props; 33 | 34 | if (typeof initials === 'string') 35 | return initials; 36 | 37 | if (typeof initials === 'function') 38 | return initials(name, this.props); 39 | 40 | return defaultInitials(name, this.props); 41 | } 42 | 43 | getValue() { 44 | if(this.props.name) 45 | return this.getInitials(); 46 | 47 | if(this.props.value) 48 | return this.props.value; 49 | 50 | return null; 51 | } 52 | 53 | getColor() { 54 | const {color, colors, name, email, value} = this.props; 55 | const colorValue = name || email || value; 56 | return color || getRandomColor(colorValue, colors); 57 | } 58 | 59 | get = (setState) => { 60 | const value = this.getValue(); 61 | 62 | if (!value) 63 | return setState(null); 64 | 65 | setState({ 66 | sourceName: 'text', 67 | value: value, 68 | color: this.getColor() 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import retina from 'is-retina'; 4 | 5 | const IS_RETINA = retina(); 6 | 7 | export 8 | function fetch(url, successCb, errorCb) { 9 | const request = new XMLHttpRequest(); 10 | request.onreadystatechange = function() { 11 | if (request.readyState === 4) { 12 | if (request.status === 200) { 13 | const data = JSON.parse(request.responseText); 14 | successCb(data); 15 | } else { 16 | errorCb(request.status); 17 | } 18 | } 19 | }; 20 | request.open('GET', url, true); 21 | request.send(); 22 | } 23 | 24 | export 25 | function fetchJSONP(url, successCb, errorCb) { 26 | const callbackName = 'jsonp_cb_' + Math.round(100000 * Math.random()); 27 | 28 | const script = document.createElement('script'); 29 | script.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'callback=' + callbackName; 30 | document.body.appendChild(script); 31 | 32 | script.onerror = function() { 33 | errorCb(); 34 | }; 35 | 36 | window[callbackName] = function(data) { 37 | delete window[callbackName]; 38 | document.body.removeChild(script); 39 | successCb(data); 40 | }; 41 | } 42 | 43 | // https://webaim.org/resources/contrastchecker/ 44 | export 45 | const defaultColors = [ 46 | '#A62A21', 47 | '#7e3794', 48 | '#0B51C1', 49 | '#3A6024', 50 | '#A81563', 51 | '#B3003C' 52 | ]; 53 | 54 | // https://regex101.com/r/YEsPER/1 55 | // https://developer.mozilla.org/en-US/docs/Web/CSS/length 56 | const reSize = /^([-+]?(?:\d+(?:\.\d+)?|\.\d+))([a-z]{2,4}|%)?$/; 57 | 58 | // https://en.wikipedia.org/wiki/Linear_congruential_generator 59 | function _stringAsciiPRNG(value, m) { 60 | // Xn+1 = (a * Xn + c) % m 61 | // 0 < a < m 62 | // 0 <= c < m 63 | // 0 <= X0 < m 64 | 65 | const charCodes = [...value].map(letter => letter.charCodeAt(0)); 66 | const len = charCodes.length; 67 | 68 | const a = (len % (m - 1)) + 1; 69 | const c = charCodes.reduce((current, next) => current + next) % m; 70 | 71 | let random = charCodes[0] % m; 72 | for (let i = 0; i < len; i++) 73 | random = ((a * random) + c) % m; 74 | 75 | return random; 76 | } 77 | 78 | export 79 | function getRandomColor(value, colors = defaultColors) 80 | { 81 | // if no value is passed, always return transparent color otherwise 82 | // a rerender would show a new color which would will 83 | // give strange effects when an interface is loading 84 | // and gets rerendered a few consequent times 85 | if(!value) 86 | return 'transparent'; 87 | 88 | // value based random color index 89 | // the reason we don't just use a random number is to make sure that 90 | // a certain value will always get the same color assigned given 91 | // a fixed set of colors 92 | const colorIndex = _stringAsciiPRNG(value, colors.length); 93 | return colors[colorIndex]; 94 | } 95 | 96 | export 97 | function parseSize(size) { 98 | size = '' + size; 99 | 100 | const [, 101 | value = 0, 102 | unit = 'px' 103 | ] = reSize.exec(size) || []; 104 | 105 | return { 106 | value: parseFloat(value), 107 | str: value + unit, 108 | unit 109 | }; 110 | } 111 | 112 | /** 113 | * Calculate absolute size in pixels we want for the images 114 | * that get requested from the various sources. They don't 115 | * understand relative sizes like `em` or `vww`. We select 116 | * a fixed size of 512px when we can't detect the true pixel size. 117 | */ 118 | export function getImageSize(size) { 119 | size = parseSize(size); 120 | 121 | if (isNaN(size.value)) // invalid size, use fallback 122 | size = 512; 123 | else if (size.unit === 'px') // px are good, use them 124 | size = size.value; 125 | else if (size.value === 0) // relative 0 === absolute 0 126 | size = 0; 127 | else // anything else is unknown, use fallback 128 | size = 512; 129 | 130 | if (IS_RETINA) 131 | size = size * 2; 132 | 133 | return size; 134 | } 135 | 136 | export 137 | function defaultInitials(name, { maxInitials }) { 138 | return name.split(/\s/) 139 | .map(part => part.substring(0, 1).toUpperCase()) 140 | .filter(v => !!v) 141 | .slice(0, maxInitials) 142 | .join('') 143 | .toUpperCase(); 144 | } 145 | 146 | /** 147 | * Grouped timeouts reduce the amount of timeouts trigged 148 | * by grouping multiple handlers into a single setTimeout call. 149 | * 150 | * This reduces accuracy of the timeout but will be less expensive 151 | * when multiple avatar have been loaded into view. 152 | */ 153 | const timeoutGroups = {}; 154 | 155 | export 156 | function setGroupedTimeout(fn, ttl) { 157 | if (timeoutGroups[ttl]) { 158 | timeoutGroups[ttl].push(fn); 159 | return; 160 | } 161 | 162 | const callbacks = timeoutGroups[ttl] = [fn]; 163 | setTimeout(() => { 164 | delete timeoutGroups[ttl]; 165 | callbacks.forEach(cb => cb()); 166 | }, ttl); 167 | } 168 | 169 | export 170 | function getNullableText(...args) { 171 | for (const arg of args) { 172 | if (arg || arg === '') 173 | return arg; 174 | 175 | if (arg === false || arg === null) 176 | return null; 177 | } 178 | 179 | return; 180 | } 181 | 182 | export 183 | function calculateBorderRadius(round) { 184 | if (round === true) 185 | return '100%'; 186 | 187 | if (round === false) 188 | return; 189 | 190 | return round; 191 | } 192 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "noEmit": true, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const path = require('path'); 4 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 5 | 6 | const rel = (p) => path.resolve(__dirname, p); 7 | const isDev = (process.env.NODE_ENV !== 'production'); 8 | const plugins = []; 9 | 10 | if (!isDev) { 11 | plugins.push(new BundleAnalyzerPlugin({ 12 | analyzerMode: 'static', 13 | openAnalyzer: false 14 | })); 15 | } 16 | 17 | module.exports = { 18 | mode: isDev ? 'development' : 'production', 19 | 20 | entry: './demo', 21 | 22 | output: { 23 | path: rel('build'), 24 | filename: 'demo.js' 25 | }, 26 | 27 | module: { 28 | rules: [{ 29 | enforce: 'pre', 30 | test: /\.js$/, 31 | use: 'eslint-loader' 32 | }, { 33 | test: /\.js$/, 34 | exclude: /node_modules/, 35 | use: 'babel-loader' 36 | }, { 37 | test: /\.(css|html|jpe?g|png)$/, 38 | use: [{ 39 | loader: 'file-loader', 40 | options: { 41 | name: '[name].[ext]' 42 | } 43 | }] 44 | }] 45 | }, 46 | 47 | devServer: { 48 | static: { 49 | directory: rel('build'), 50 | }, 51 | compress: true, 52 | port: 8000 53 | }, 54 | 55 | watchOptions: { 56 | aggregateTimeout: 500 57 | }, 58 | 59 | plugins: [ 60 | ...plugins 61 | ] 62 | }; 63 | --------------------------------------------------------------------------------