diff --git a/src/components/views/elements/Flair.js b/src/components/views/elements/Flair.js new file mode 100644 index 0000000000..6ec9f36424 --- /dev/null +++ b/src/components/views/elements/Flair.js @@ -0,0 +1,179 @@ +/* + Copyright 2017 New Vector 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'; + +import React from 'react'; +import PropTypes from 'prop-types'; +import {MatrixClient} from 'matrix-js-sdk'; + +const BULK_REQUEST_DEBOUNCE_MS = 200; + +// TODO: Cache-busting based on time. (The server won't inform us of membership changes.) +// This applies to userGroups and groupProfiles. We can provide a slightly better UX by +// cache-busting when the current user joins/leaves a group. +const userGroups = { + // $userId: ['+group1:domain', '+group2:domain', ...] +}; + +const groupProfiles = { +// $groupId: { +// avatar_url: 'mxc://...' +// } +}; + +// Represents all unsettled promises to retrieve the groups for each userId. When a promise +// is settled, it is deleted from this object. +const usersPending = { +// $userId: { +// prom: Promise +// resolve: () => {} +// reject: () => {} +// } +}; + +let debounceTimeoutID; +function getPublicGroupsCached(matrixClient, userId) { + if (userGroups[userId]) { + return Promise.resolve(userGroups[userId]); + } + + // Bulk lookup ongoing, return promise to resolve/reject + if (usersPending[userId]) { + return usersPending[userId].prom; + } + + usersPending[userId] = {}; + usersPending[userId].prom = new Promise((resolve, reject) => { + usersPending[userId].resolve = resolve; + usersPending[userId].reject = reject; + }).then((groups) => { + userGroups[userId] = groups; + // TODO: Reset cache at this point + return userGroups[userId]; + }).catch((err) => { + throw err; + }).finally(() => { + delete usersPending[userId]; + }); + + if (debounceTimeoutID) clearTimeout(debounceTimeoutID); + debounceTimeoutID = setTimeout(() => { + batchedGetPublicGroups(matrixClient); + }, BULK_REQUEST_DEBOUNCE_MS); + + return usersPending[userId].prom; +} + +async function batchedGetPublicGroups(matrixClient) { + // Take the userIds from the keys of usersPending + const usersInFlight = Object.keys(usersPending); + let resp = { + users: [], + }; + try { + resp = await matrixClient.getPublicGroups(usersInFlight); + } catch (err) { + // Propagate the same error to all usersInFlight + usersInFlight.forEach((userId) => { + usersPending[userId].prom.reject(err); + }); + return; + } + const updatedUserGroups = resp.users; + usersInFlight.forEach((userId) => { + usersPending[userId].resolve(updatedUserGroups[userId] || []); + }); +} + +async function getGroupProfileCached(matrixClient, groupId) { + if (groupProfiles[groupId]) { + return groupProfiles[groupId]; + } + + groupProfiles[groupId] = await matrixClient.getGroupProfile(groupId); + return groupProfiles[groupId]; +} + +export default class Flair extends React.Component { + constructor() { + super(); + this.state = { + avatarUrls: [], + }; + } + + componentWillUnmount() { + this._unmounted = true; + } + + async componentWillMount() { + this._unmounted = false; + this._generateAvatars(); + } + + async _getAvatarUrls(groups) { + const profiles = []; + for (const groupId of groups) { + let groupProfile = null; + try { + groupProfile = await getGroupProfileCached(this.context.matrixClient, groupId); + } catch (err) { + console.error('Could not get profile for group', groupId, err); + } + profiles.push(groupProfile); + } + + const avatarUrls = profiles.filter((p) => p !== null).map((p) => p.avatar_url); + return avatarUrls; + } + + async _generateAvatars() { + let groups; + try { + groups = await getPublicGroupsCached(this.context.matrixClient, this.props.userId); + } catch (err) { + console.error('Could not get groups for user', this.props.userId, err); + } + const avatarUrls = await this._getAvatarUrls(groups); + if (!this.unmounted) { + this.setState({avatarUrls}); + } + } + + render() { + if (this.state.avatarUrls.length === 0) { + return
; + } + const avatars = this.state.avatarUrls.map((avatarUrl, index) => { + const httpUrl = this.context.matrixClient.mxcUrlToHttp(avatarUrl, 14, 14, 'scale', false); + return ; + }); + return ( + + {avatars} + + ); + } +} + +Flair.propTypes = { + userId: PropTypes.string, +}; + +Flair.contextTypes = { + matrixClient: React.PropTypes.instanceOf(MatrixClient).isRequired, +}; diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.js index e224714a27..e9d2fe62ff 100644 --- a/src/components/views/messages/SenderProfile.js +++ b/src/components/views/messages/SenderProfile.js @@ -18,6 +18,7 @@ import React from 'react'; import sdk from '../../../index'; +import Flair from '../elements/Flair.js'; export default function SenderProfile(props) { const EmojiText = sdk.getComponent('elements.EmojiText'); @@ -30,8 +31,11 @@ export default function SenderProfile(props) { } return ( - {`${name || ''} ${props.aux || ''}`} +
+ {name || ''} + {props.enableFlair ? : null} + {props.aux ? props.aux : null} +
); } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index a6f8ed5542..7328cfe0b6 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -508,10 +508,10 @@ module.exports = withMatrixClient(React.createClass({ if (msgtype === 'm.image') aux = _t('sent an image'); else if (msgtype === 'm.video') aux = _t('sent a video'); else if (msgtype === 'm.file') aux = _t('uploaded a file'); - sender = ; + sender = ; } else { - sender = ; + sender = ; } }