Merge pull request #1351 from matrix-org/luke/feature-flair

Flair!
This commit is contained in:
Luke Barnard 2017-09-19 15:05:40 +01:00 committed by GitHub
commit b4ed9d7c87
5 changed files with 227 additions and 5 deletions

View file

@ -33,11 +33,17 @@ export default {
// XXX: Always use default, ignore localStorage and remove from labs // XXX: Always use default, ignore localStorage and remove from labs
override: true, override: true,
}, },
{
name: "-",
id: 'feature_flair',
default: false,
},
], ],
// horrible but it works. The locality makes this somewhat more palatable. // horrible but it works. The locality makes this somewhat more palatable.
doTranslations: function() { doTranslations: function() {
this.LABS_FEATURES[0].name = _t("Matrix Apps"); this.LABS_FEATURES[0].name = _t("Matrix Apps");
this.LABS_FEATURES[1].name = _t("Flair");
}, },
loadProfileInfo: function() { loadProfileInfo: function() {

View file

@ -0,0 +1,209 @@
/*
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';
import UserSettingsStore from '../../../UserSettingsStore';
const BULK_REQUEST_DEBOUNCE_MS = 200;
// Does the server support groups? Assume yes until we receive M_UNRECOGNIZED.
// If true, flair can function and we should keep sending requests for groups and avatars.
let groupSupport = true;
// 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 getPublicisedGroupsCached(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];
});
// This debounce will allow consecutive requests for the public groups of users that
// are sent in intervals of < BULK_REQUEST_DEBOUNCE_MS to be batched and only requested
// when no more requests are received within the next BULK_REQUEST_DEBOUNCE_MS. The naive
// implementation would do a request that only requested the groups for `userId`, leading
// to a worst and best case of 1 user per request. This implementation's worst is still
// 1 user per request but only if the requests are > BULK_REQUEST_DEBOUNCE_MS apart and the
// best case is N users per request.
//
// This is to reduce the number of requests made whilst trading off latency when viewing
// a Flair component.
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.getPublicisedGroups(usersInFlight);
} catch (err) {
// Propagate the same error to all usersInFlight
usersInFlight.forEach((userId) => {
usersPending[userId].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;
}
componentWillMount() {
this._unmounted = false;
if (UserSettingsStore.isFeatureEnabled('feature_flair') && groupSupport) {
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 getPublicisedGroupsCached(this.context.matrixClient, this.props.userId);
} catch (err) {
// Indicate whether the homeserver supports groups
if (err.errcode === 'M_UNRECOGNIZED') {
console.warn('Cannot display flair, server does not support groups');
groupSupport = false;
// Return silently to avoid spamming for non-supporting servers
return;
}
console.error('Could not get groups for user', this.props.userId, err);
}
if (!groups || groups.length === 0) {
return;
}
const avatarUrls = await this._getAvatarUrls(groups);
if (!this.unmounted) {
this.setState({avatarUrls});
}
}
render() {
if (this.state.avatarUrls.length === 0) {
return <div />;
}
const avatars = this.state.avatarUrls.map((avatarUrl, index) => {
const httpUrl = this.context.matrixClient.mxcUrlToHttp(avatarUrl, 14, 14, 'scale', false);
return <img key={index} src={httpUrl} width="14px" height="14px"/>;
});
return (
<span className="mx_Flair" style={{"marginLeft": "5px", "verticalAlign": "-3px"}}>
{avatars}
</span>
);
}
}
Flair.propTypes = {
userId: PropTypes.string,
};
// TODO: We've decided that all components should follow this pattern, which means removing withMatrixClient and using
// this.context.matrixClient everywhere instead of this.props.matrixClient.
// See https://github.com/vector-im/riot-web/issues/4951.
Flair.contextTypes = {
matrixClient: React.PropTypes.instanceOf(MatrixClient).isRequired,
};

View file

@ -18,6 +18,7 @@
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import Flair from '../elements/Flair.js';
export default function SenderProfile(props) { export default function SenderProfile(props) {
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
@ -30,8 +31,11 @@ export default function SenderProfile(props) {
} }
return ( return (
<EmojiText className="mx_SenderProfile" dir="auto" <div className="mx_SenderProfile" dir="auto" onClick={props.onClick}>
onClick={props.onClick}>{`${name || ''} ${props.aux || ''}`}</EmojiText> <EmojiText>{name || ''}</EmojiText>
{props.enableFlair ? <Flair userId={mxEvent.getSender()} /> : null}
{props.aux ? <EmojiText> props.aux</EmojiText> : null}
</div>
); );
} }

View file

@ -508,10 +508,10 @@ module.exports = withMatrixClient(React.createClass({
if (msgtype === 'm.image') aux = _t('sent an image'); if (msgtype === 'm.image') aux = _t('sent an image');
else if (msgtype === 'm.video') aux = _t('sent a video'); else if (msgtype === 'm.video') aux = _t('sent a video');
else if (msgtype === 'm.file') aux = _t('uploaded a file'); else if (msgtype === 'm.file') aux = _t('uploaded a file');
sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />; sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} enableFlair={!aux} aux={aux} />;
} }
else { else {
sender = <SenderProfile mxEvent={this.props.mxEvent} />; sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={true} />;
} }
} }

View file

@ -883,5 +883,8 @@
"Failed to invite users to %(groupId)s": "Failed to invite users to %(groupId)s", "Failed to invite users to %(groupId)s": "Failed to invite users to %(groupId)s",
"Unable to reject invite": "Unable to reject invite", "Unable to reject invite": "Unable to reject invite",
"Leave Group": "Leave Group", "Leave Group": "Leave Group",
"Leave %(groupName)s?": "Leave %(groupName)s?" "Leave %(groupName)s?": "Leave %(groupName)s?",
"%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s",
"Robot check is currently unavailable on desktop - please use a <a>web browser</a>": "Robot check is currently unavailable on desktop - please use a <a>web browser</a>",
"Flair": "Flair"
} }