From fec266f1c025e53962b0dd50128a06f915ea94eb Mon Sep 17 00:00:00 2001
From: David Baker <dave@matrix.org>
Date: Thu, 13 Aug 2015 19:30:02 +0100
Subject: [PATCH] Move avatars into their own components so I can add
 functionality like custom default avatars and onerror sources without having
 to add it in 13 separate places. Add the aforementioned features.

---
 skins/base/css/atoms/MemberAvatar.css         | 22 ++++++
 skins/base/css/molecules/MemberTile.css       |  6 --
 skins/base/views/atoms/MemberAvatar.js        | 34 +++++++++
 skins/base/views/atoms/RoomAvatar.js          | 34 +++++++++
 skins/base/views/molecules/EventAsTextTile.js |  3 +-
 skins/base/views/molecules/MRoomMemberTile.js |  3 +-
 skins/base/views/molecules/MemberInfo.js      |  6 +-
 skins/base/views/molecules/MemberTile.js      |  7 +-
 skins/base/views/molecules/MessageComposer.js |  5 +-
 skins/base/views/molecules/MessageTile.js     |  3 +-
 skins/base/views/molecules/RoomHeader.js      | 10 ++-
 skins/base/views/molecules/RoomTile.js        |  8 +-
 .../views/molecules/voip/MCallAnswerTile.js   |  2 +-
 .../views/molecules/voip/MCallHangupTile.js   |  2 +-
 .../views/molecules/voip/MCallInviteTile.js   |  2 +-
 skins/base/views/organisms/Notifier.js        |  7 +-
 src/ComponentBroker.js                        |  2 +
 src/DefaultAvatar.js                          | 38 ++++++++++
 src/controllers/atoms/MemberAvatar.js         | 76 +++++++++++++++++++
 src/controllers/atoms/RoomAvatar.js           | 67 ++++++++++++++++
 20 files changed, 314 insertions(+), 23 deletions(-)
 create mode 100644 skins/base/css/atoms/MemberAvatar.css
 create mode 100644 skins/base/views/atoms/MemberAvatar.js
 create mode 100644 skins/base/views/atoms/RoomAvatar.js
 create mode 100644 src/DefaultAvatar.js
 create mode 100644 src/controllers/atoms/MemberAvatar.js
 create mode 100644 src/controllers/atoms/RoomAvatar.js

diff --git a/skins/base/css/atoms/MemberAvatar.css b/skins/base/css/atoms/MemberAvatar.css
new file mode 100644
index 00000000..6422df79
--- /dev/null
+++ b/skins/base/css/atoms/MemberAvatar.css
@@ -0,0 +1,22 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_MemberAvatar {
+    z-index: 20;
+    border-radius: 20px;
+    background-color: #dbdbdb;
+}
+
diff --git a/skins/base/css/molecules/MemberTile.css b/skins/base/css/molecules/MemberTile.css
index 2033b5b0..f296668f 100644
--- a/skins/base/css/molecules/MemberTile.css
+++ b/skins/base/css/molecules/MemberTile.css
@@ -31,12 +31,6 @@ limitations under the License.
     position: relative;
 }
 
-.mx_MemberTile_avatarImg {
-    z-index: 20;
-    border-radius: 20px;
-    background-color: #dbdbdb;
-}
-
 .mx_MemberTile_inviteEditing {
     display: initial ! important;
 }
diff --git a/skins/base/views/atoms/MemberAvatar.js b/skins/base/views/atoms/MemberAvatar.js
new file mode 100644
index 00000000..4ef5fea9
--- /dev/null
+++ b/skins/base/views/atoms/MemberAvatar.js
@@ -0,0 +1,34 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var MemberAvatarController = require("../../../../src/controllers/atoms/MemberAvatar");
+
+module.exports = React.createClass({
+    displayName: 'MemberAvatar',
+    mixins: [MemberAvatarController],
+
+    render: function() {
+        return (
+            <img className="mx_MemberAvatar" src={this.state.imageUrl}
+                onerror={this.onError}
+                width={this.props.width} height={this.props.height} />
+        );
+    }
+});
diff --git a/skins/base/views/atoms/RoomAvatar.js b/skins/base/views/atoms/RoomAvatar.js
new file mode 100644
index 00000000..48febbc8
--- /dev/null
+++ b/skins/base/views/atoms/RoomAvatar.js
@@ -0,0 +1,34 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var RoomAvatarController = require("../../../../src/controllers/atoms/RoomAvatar");
+
+module.exports = React.createClass({
+    displayName: 'RoomAvatar',
+    mixins: [RoomAvatarController],
+
+    render: function() {
+        return (
+            <img className="mx_RoomAvatar" src={this.state.imageUrl} onerror={this.onError}
+                width={this.props.width} height={this.props.height}
+            />
+        );
+    }
+});
diff --git a/skins/base/views/molecules/EventAsTextTile.js b/skins/base/views/molecules/EventAsTextTile.js
index f53d83c6..bc585b81 100644
--- a/skins/base/views/molecules/EventAsTextTile.js
+++ b/skins/base/views/molecules/EventAsTextTile.js
@@ -22,6 +22,7 @@ var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 var EventAsTextTileController = require("../../../../src/controllers/molecules/EventAsTextTile");
 var ComponentBroker = require('../../../../src/ComponentBroker');
 var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp');
+var MemberAvatar = ComponentBroker.get('atoms/MemberAvatar');
 var TextForEvent = require("../../../../src/TextForEvent");
 
 module.exports = React.createClass({
@@ -34,7 +35,7 @@ module.exports = React.createClass({
         return (
             <div className="mx_MessageTile mx_MessageTile_notice">
                 <div className="mx_MessageTile_avatar">
-                    <img src={ this.props.mxEvent.sender ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.mxEvent.sender, 40, 40, "crop") : null } width="40" height="40" alt=""/>
+                    <MemberAvatar member={this.props.mxEvent.sender} />
                 </div>            
                 { timestamp }
                 <span className="mx_SenderProfile"></span>
diff --git a/skins/base/views/molecules/MRoomMemberTile.js b/skins/base/views/molecules/MRoomMemberTile.js
index 3ca14283..fb65d375 100644
--- a/skins/base/views/molecules/MRoomMemberTile.js
+++ b/skins/base/views/molecules/MRoomMemberTile.js
@@ -24,6 +24,7 @@ var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 var ComponentBroker = require('../../../../src/ComponentBroker');
 var TextForEvent = require('../../../../src/TextForEvent');
 var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp');
+var MemberAvatar = ComponentBroker.get('atoms/MemberAvatar');
 
 module.exports = React.createClass({
     displayName: 'MRoomMemberTile',
@@ -41,7 +42,7 @@ module.exports = React.createClass({
         return (
             <div className="mx_MessageTile mx_MessageTile_notice">
                 <div className="mx_MessageTile_avatar">
-                    <img src={ this.props.mxEvent.target ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.mxEvent.target, 40, 40, "crop") : null } width="40" height="40" alt=""/>
+                    <MemberAvatar member={this.props.mxEvent.sender} />
                 </div>            
                 { timestamp }
                 <span className="mx_SenderProfile"></span>
diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js
index 3e4dbcd7..e79b99a2 100644
--- a/skins/base/views/molecules/MemberInfo.js
+++ b/skins/base/views/molecules/MemberInfo.js
@@ -20,6 +20,8 @@ var React = require('react');
 
 var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 var MemberInfoController = require("../../../../src/controllers/molecules/MemberInfo");
+var ComponentBroker = require('../../../../src/ComponentBroker');
+var MemberAvatar = ComponentBroker.get('atoms/MemberAvatar');
 
 module.exports = React.createClass({
     displayName: 'MemberInfo',
@@ -96,9 +98,7 @@ module.exports = React.createClass({
                 <img className="mx_MemberInfo_chevron" src="img/chevron-right.png" width="9" height="16" />
                 <div className="mx_MemberInfo_shim"></div>
                 <div className="mx_MemberInfo_avatar">
-                    <img className="mx_MemberInfo_avatarImg"
-                         src={ this.props.member ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.member, 128, 128, "crop") : null }
-                         width="128" height="128" alt=""/>
+                    <MemberAvatar member={this.props.member} width={128} height={128} />
                 </div>
                 <div className="mx_MemberInfo_field">{this.props.member.userId}</div>
                 {opLabel}
diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index cf5980b9..bd184051 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -24,6 +24,7 @@ var Modal = require("../../../../src/Modal");
 var MemberTileController = require("../../../../src/controllers/molecules/MemberTile");
 var MemberInfo = ComponentBroker.get('molecules/MemberInfo');
 var ErrorDialog = ComponentBroker.get("organisms/ErrorDialog");
+var MemberAvatar = ComponentBroker.get('atoms/MemberAvatar');
 
 // The Lato WOFF doesn't include sensible combining diacritics, so Chrome chokes on rendering them.
 // Revert to Arial when this happens, which on OSX works at least.
@@ -95,10 +96,8 @@ module.exports = React.createClass({
         return (
             <div className={mainClassName} onMouseEnter={ this.mouseEnter } onMouseLeave={ this.mouseLeave }>
                 <div className="mx_MemberTile_avatar">
-                    <img className="mx_MemberTile_avatarImg"
-                         src={ this.props.member ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.member, 40, 40, "crop") : null }
-                         width="40" height="40" alt=""/>
-                         { power }
+                    <MemberAvatar member={this.props.member} />
+                     { power }
                 </div>
                 { nameEl }
             </div>
diff --git a/skins/base/views/molecules/MessageComposer.js b/skins/base/views/molecules/MessageComposer.js
index 639c5bff..a8d8a4eb 100644
--- a/skins/base/views/molecules/MessageComposer.js
+++ b/skins/base/views/molecules/MessageComposer.js
@@ -22,6 +22,9 @@ var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 var MessageComposerController = require("../../../../src/controllers/molecules/MessageComposer");
 var ContentMessages = require("../../../../src/ContentMessages");
 
+var ComponentBroker = require('../../../../src/ComponentBroker');
+var MemberAvatar = ComponentBroker.get('atoms/MemberAvatar');
+
 module.exports = React.createClass({
     displayName: 'MessageComposer',
     mixins: [MessageComposerController],
@@ -47,7 +50,7 @@ module.exports = React.createClass({
                 <div className="mx_MessageComposer_wrapper">
                     <div className="mx_MessageComposer_row">
                         <div className="mx_MessageComposer_avatar">
-                            <img src={ MatrixClientPeg.get().getAvatarUrlForMember(me, 40, 40, "crop") } width="40" height="40" alt=""/>
+                            <MemberAvatar member={me} />
                         </div>
                         <div className="mx_MessageComposer_input">
                             <textarea ref="textarea" onKeyDown={this.onKeyDown} placeholder="Type a message" />
diff --git a/skins/base/views/molecules/MessageTile.js b/skins/base/views/molecules/MessageTile.js
index b9a57079..d6b8713f 100644
--- a/skins/base/views/molecules/MessageTile.js
+++ b/skins/base/views/molecules/MessageTile.js
@@ -25,6 +25,7 @@ var ComponentBroker = require('../../../../src/ComponentBroker');
 
 var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp');
 var SenderProfile = ComponentBroker.get('molecules/SenderProfile');
+var MemberAvatar = ComponentBroker.get('atoms/MemberAvatar');
 
 var UnknownMessageTile = ComponentBroker.get('molecules/UnknownMessageTile');
 
@@ -64,7 +65,7 @@ module.exports = React.createClass({
         if (!this.props.continuation) {
             avatar = (
                 <div className="mx_MessageTile_avatar">
-                    <img src={ this.props.mxEvent.sender ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.mxEvent.sender, 40, 40, "crop") : null } width="40" height="40" alt=""/>
+                    <MemberAvatar member={this.props.mxEvent.sender} />
                 </div>
             );
             sender = <SenderProfile mxEvent={this.props.mxEvent} />;
diff --git a/skins/base/views/molecules/RoomHeader.js b/skins/base/views/molecules/RoomHeader.js
index 8e0cadc2..e3003b88 100644
--- a/skins/base/views/molecules/RoomHeader.js
+++ b/skins/base/views/molecules/RoomHeader.js
@@ -22,6 +22,7 @@ var ComponentBroker = require('../../../../src/ComponentBroker');
 var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 var RoomHeaderController = require("../../../../src/controllers/molecules/RoomHeader");
 var EditableText = ComponentBroker.get("atoms/EditableText");
+var RoomAvatar = ComponentBroker.get('atoms/RoomAvatar');
 
 module.exports = React.createClass({
     displayName: 'RoomHeader',
@@ -93,11 +94,18 @@ module.exports = React.createClass({
                 );
             }
 
+            var roomAvatar = null;
+            if (this.props.room) {
+                roomAvatar = (
+                    <RoomAvatar room={this.props.room} />
+                );
+            }
+
             header =
                 <div className="mx_RoomHeader_wrapper">
                     <div className="mx_RoomHeader_leftRow">
                         <div className="mx_RoomHeader_avatar">
-                            <img src={ MatrixClientPeg.get().getAvatarUrlForRoom(this.props.room, 48, 48, "crop") } width="48" height="48" alt=""/>
+                            { roomAvatar }
                         </div>
                         <div className="mx_RoomHeader_info">
                             { name }
diff --git a/skins/base/views/molecules/RoomTile.js b/skins/base/views/molecules/RoomTile.js
index 6b80fc8a..b8e41fb8 100644
--- a/skins/base/views/molecules/RoomTile.js
+++ b/skins/base/views/molecules/RoomTile.js
@@ -23,6 +23,9 @@ var RoomTileController = require("../../../../src/controllers/molecules/RoomTile
 
 var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 
+var ComponentBroker = require('../../../../src/ComponentBroker');
+var RoomAvatar = ComponentBroker.get('atoms/RoomAvatar');
+
 module.exports = React.createClass({
     displayName: 'RoomTile',
     mixins: [RoomTileController],
@@ -57,7 +60,10 @@ module.exports = React.createClass({
         */
         return (
             <div className={classes} onClick={this.onClick}>
-                <div className="mx_RoomTile_avatar"><img src={ MatrixClientPeg.get().getAvatarUrlForRoom(this.props.room, 40, 40, "crop") } width="40" height="40" alt=""/>{ badge }</div>
+                <div className="mx_RoomTile_avatar">
+                    <RoomAvatar room={this.props.room} />
+                    { badge }
+                </div>
                 <div className="mx_RoomTile_name">{name}</div>
             </div>
         );
diff --git a/skins/base/views/molecules/voip/MCallAnswerTile.js b/skins/base/views/molecules/voip/MCallAnswerTile.js
index b7ea3cad..0dcce825 100644
--- a/skins/base/views/molecules/voip/MCallAnswerTile.js
+++ b/skins/base/views/molecules/voip/MCallAnswerTile.js
@@ -36,7 +36,7 @@ module.exports = React.createClass({
         return (
             <div className="mx_MessageTile mx_MessageTile_notice">
                 <div className="mx_MessageTile_avatar">
-                    <img src={ this.props.mxEvent.sender ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.mxEvent.sender, 40, 40, "crop") : null } width="40" height="40" alt=""/>
+                    <MemberAvatar member={this.props.mxEvent.sender} />
                 </div>            
                 <MessageTimestamp ts={this.props.mxEvent.getTs()} />
                 <span className="mx_SenderProfile"></span>
diff --git a/skins/base/views/molecules/voip/MCallHangupTile.js b/skins/base/views/molecules/voip/MCallHangupTile.js
index 261bd8d1..94308f9c 100644
--- a/skins/base/views/molecules/voip/MCallHangupTile.js
+++ b/skins/base/views/molecules/voip/MCallHangupTile.js
@@ -36,7 +36,7 @@ module.exports = React.createClass({
         return (
             <div className="mx_MessageTile mx_MessageTile_notice">
                 <div className="mx_MessageTile_avatar">
-                    <img src={ this.props.mxEvent.sender ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.mxEvent.sender, 40, 40, "crop") : null } width="40" height="40" alt=""/>
+                    <MemberAvatar member={this.props.mxEvent.sender} />
                 </div>            
                 <MessageTimestamp ts={this.props.mxEvent.getTs()} />
                 <span className="mx_SenderProfile"></span>
diff --git a/skins/base/views/molecules/voip/MCallInviteTile.js b/skins/base/views/molecules/voip/MCallInviteTile.js
index ed73553b..1dc08f1d 100644
--- a/skins/base/views/molecules/voip/MCallInviteTile.js
+++ b/skins/base/views/molecules/voip/MCallInviteTile.js
@@ -42,7 +42,7 @@ module.exports = React.createClass({
         return (
             <div className="mx_MessageTile mx_MessageTile_notice">
                 <div className="mx_MessageTile_avatar">
-                    <img src={ this.props.mxEvent.sender ? MatrixClientPeg.get().getAvatarUrlForMember(this.props.mxEvent.sender, 40, 40, "crop") : null } width="40" height="40" alt=""/>
+                    <MemberAvatar member={this.props.mxEvent.sender} />
                 </div>            
                 <MessageTimestamp ts={this.props.mxEvent.getTs()} />
                 <span className="mx_SenderProfile"></span>
diff --git a/skins/base/views/organisms/Notifier.js b/skins/base/views/organisms/Notifier.js
index 7df76e29..8dcb03da 100644
--- a/skins/base/views/organisms/Notifier.js
+++ b/skins/base/views/organisms/Notifier.js
@@ -23,6 +23,9 @@ var TextForEvent = require("../../../../src/TextForEvent");
 var extend = require("../../../../src/extend");
 var dis = require("../../../../src/dispatcher");
 
+var ComponentBroker = require("../../../../src/ComponentBroker");
+var MemberAvatar = ComponentBroker.get("atoms/MemberAvatar");
+
 
 var NotifierView = {
     notificationMessageForEvent: function(ev) {
@@ -57,11 +60,13 @@ var NotifierView = {
             if (ev.getContent().body) msg = ev.getContent().body;
         }
 
+        var avatarUrlrl = MemberAvatar.avatarUrlForMember(ev.sender);
+
         var notification = new global.Notification(
             title,
             {
                 "body": msg,
-                "icon": MatrixClientPeg.get().getAvatarUrlForMember(ev.sender)
+                "icon": avatarUrl
             }
         );
 
diff --git a/src/ComponentBroker.js b/src/ComponentBroker.js
index 2476ff6a..a869b431 100644
--- a/src/ComponentBroker.js
+++ b/src/ComponentBroker.js
@@ -103,6 +103,8 @@ require('../skins/base/views/molecules/RoomDropTarget');
 require('../skins/base/views/molecules/BottomLeftMenu');
 require('../skins/base/views/molecules/DateSeparator');
 require('../skins/base/views/atoms/voip/VideoFeed');
+require('../skins/base/views/atoms/MemberAvatar');
+require('../skins/base/views/atoms/RoomAvatar');
 require('../skins/base/views/atoms/ImageView');
 require('../skins/base/views/molecules/voip/VideoView');
 require('../skins/base/views/molecules/voip/CallView');
diff --git a/src/DefaultAvatar.js b/src/DefaultAvatar.js
new file mode 100644
index 00000000..5183d33b
--- /dev/null
+++ b/src/DefaultAvatar.js
@@ -0,0 +1,38 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+module.exports = {
+    defaultAvatarUrlForString: function(s) {
+        var total = 0;
+        for (var i = 0; i < s.length; ++i) {
+            total += s.charCodeAt(i);
+        }
+        switch (total % 3) {
+            case 0:
+                return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9QjNbxSKP4eagAFnTseHFErFYLBaLxWKxWCwWi8Vi8cX4CzAABSwCRWJw31gAAAAASUVORK5CYII=";
+                break;
+            case 1:
+                return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9chOaxgCP4eagAFk9seHFErFYLBaLxWKxWCwWi8Vi8cX4CzAAtKMCks/JG8MAAAAASUVORK5CYII=";
+                break;
+            case 2:
+                return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9YzNayQCP4eagADldseHFErFYLBaLxWKxWCwWi8Vi8cX4CzAAyiACeHwPiu4AAAAASUVORK5CYII=";
+                break;
+        }
+    }
+}
+
diff --git a/src/controllers/atoms/MemberAvatar.js b/src/controllers/atoms/MemberAvatar.js
new file mode 100644
index 00000000..d2f4aa45
--- /dev/null
+++ b/src/controllers/atoms/MemberAvatar.js
@@ -0,0 +1,76 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var MatrixClientPeg = require('../../MatrixClientPeg');
+var DefaultAvatar = require('../../DefaultAvatar');
+
+var React = require('react');
+
+module.exports = {
+    propTypes: {
+        member: React.PropTypes.object.isRequired,
+        width: React.PropTypes.number,
+        height: React.PropTypes.number,
+        resizeMethod: React.PropTypes.string,
+    },
+
+    getDefaultProps: function() {
+        return {
+            width: 40,
+            height: 40,
+            resizeMethod: 'crop'
+        }
+    },
+
+    // takes member as an arg so it can be used if the
+    // avatars are required outsode of components
+    // (eg. in html5 desktop notifs)
+    avatarUrlForMember(member) {
+        var url = MatrixClientPeg.get().getAvatarUrlForMember(
+            member,
+            this.props.width, this.props.height, this.props.resizeMethod,
+            false
+        );
+        if (url === null) {
+            url = this.defaultAvatarUrl(member);
+        }
+        return url;
+    },
+
+    defaultAvatarUrl: function(member) {
+        return DefaultAvatar.defaultAvatarUrlForString(
+            member.userId
+        );
+    },
+
+    onError: function(ev) {
+        // don't tightloop if the browser can't load a data url
+        if (ev.target.src == this.defaultAvatarUrl()) {
+            return;
+        }
+        this.setState({
+            imageUrl: this.defaultAvatarUrl()
+        });
+    },
+
+    getInitialState: function() {
+        return {
+            imageUrl: this.avatarUrlForMember(this.props.member)
+        };
+    }
+};
diff --git a/src/controllers/atoms/RoomAvatar.js b/src/controllers/atoms/RoomAvatar.js
new file mode 100644
index 00000000..088d4694
--- /dev/null
+++ b/src/controllers/atoms/RoomAvatar.js
@@ -0,0 +1,67 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var MatrixClientPeg = require('../../MatrixClientPeg');
+var DefaultAvatar = require('../../DefaultAvatar');
+
+module.exports = {
+    getDefaultProps: function() {
+        return {
+            width: 40,
+            height: 40,
+            resizeMethod: 'crop'
+        }
+    },
+
+    // takes member as an arg so it can be used if the
+    // avatars are required outsode of components
+    // (eg. in html5 desktop notifs, although this is not)
+    avatarUrlForRoom(room) {
+        var url = MatrixClientPeg.get().getAvatarUrlForRoom(
+            room,
+            this.props.width, this.props.height, this.props.resizeMethod,
+            false
+        );
+        if (url === null) {
+            url = this.defaultAvatarUrl(room);
+        }
+        return url;
+    },
+
+    defaultAvatarUrl: function(room) {
+        return DefaultAvatar.defaultAvatarUrlForString(
+            this.props.room.roomId
+        );
+    },
+
+    onError: function(ev) {
+        // don't tightloop if the browser can't load a data url
+        if (ev.target.src == this.defaultAvatarUrl()) {
+            return;
+        }
+        this.setState({
+            imageUrl: this.defaultAvatarUrl()
+        });
+    },
+
+    getInitialState: function() {
+        return {
+            imageUrl: this.avatarUrlForRoom(this.props.room)
+        };
+    }
+};