refactoring info-view into reusable components

This commit is contained in:
or-else
2021-07-18 19:26:34 -07:00
parent fdfede5069
commit 094173fe02
6 changed files with 304 additions and 213 deletions
@@ -7,7 +7,7 @@ import BadgeList from '../widgets/badge-list.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
export default class EditAccountView extends React.Component { export default class AccountSettingsView extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -38,7 +38,7 @@ export default class EditAccountView extends React.Component {
<AvatarUpload <AvatarUpload
tinode={this.props.tinode} tinode={this.props.tinode}
avatar={this.state.avatar} avatar={this.state.avatar}
readOnly={!this.state.owner} readOnly={true}
uid={this.props.myUserId} uid={this.props.myUserId}
title={this.state.fullName} /> title={this.state.fullName} />
</div> </div>
+22 -29
View File
@@ -575,38 +575,36 @@ class InfoView extends React.Component {
<FormattedMessage id="label_topic_name" defaultMessage="Name" <FormattedMessage id="label_topic_name" defaultMessage="Name"
description="Label for editing topic name" /> description="Label for editing topic name" />
</label></div> </label></div>
<div><InPlaceEdit <div className="large">{this.state.fullName}</div>
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>} {this.state.private ?
readOnly={!this.state.owner} <>
value={this.state.fullName} <div><label className="small">
required={true} <FormattedMessage id="label_private" defaultMessage="Private comment"
onFinished={this.handleFullNameUpdate} /></div> description="Label for editing 'private'" />
</label></div>
<div className="large">{this.state.private}</div>
</>
: null
}
<div> <div>
<label className="small"> <label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
<FormattedMessage id="label_private" defaultMessage="Private comment" description="Label for user address (ID)" /></label>&nbsp;
description="Label for editing 'private'" /> <tt>{this.props.myUserId}</tt>
</label>
</div>
<div>
<FormattedMessage id="private_editing_placeholder"
defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{
(private_placeholder) => <InPlaceEdit
placeholder={private_placeholder}
value={this.state.private}
onFinished={this.handlePrivateUpdate} />
}</FormattedMessage>
</div> </div>
<BadgeList trustedBadges={this.state.trustedBadges} /> <BadgeList trustedBadges={this.state.trustedBadges} />
</div> </div>
<AvatarUpload <AvatarUpload
tinode={this.props.tinode} tinode={this.props.tinode}
avatar={this.state.avatar} avatar={this.state.avatar}
readOnly={!this.state.owner} readOnly={true}
uid={this.props.topic} uid={this.props.topic}
title={this.state.fullName} title={this.state.fullName} />
onImageUpdated={this.handleImageChanged} </div>
onError={this.props.onError} /> <div className="panel-form-row">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
</div> </div>
<div className="hr" /> <div className="hr" />
<div className="panel-form-column"> <div className="panel-form-column">
@@ -627,11 +625,6 @@ class InfoView extends React.Component {
}</FormattedMessage> }</FormattedMessage>
{this.state.moreInfoExpanded ? {this.state.moreInfoExpanded ?
<div className="panel-form-column"> <div className="panel-form-column">
<div className="panel-form-row">
<label><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>
<tt>{this.state.address}</tt>
</div>
{this.state.groupTopic ? {this.state.groupTopic ?
<div className="panel-form-row"> <div className="panel-form-row">
<label> <label>
+3 -3
View File
@@ -5,9 +5,9 @@ import ErrorPanel from '../widgets/error-panel.jsx';
import LoadSpinner from '../widgets/load-spinner.jsx'; import LoadSpinner from '../widgets/load-spinner.jsx';
import SideNavbar from '../widgets/side-navbar.jsx'; import SideNavbar from '../widgets/side-navbar.jsx';
import AccountSettingsView from './account-settings-view.jsx';
import ContactsView from './contacts-view.jsx'; import ContactsView from './contacts-view.jsx';
import CreateAccountView from './create-account-view.jsx'; import CreateAccountView from './create-account-view.jsx';
import EditAccountView from './edit-account-view.jsx';
import TopicCommonView from './topic-common-view.jsx'; import TopicCommonView from './topic-common-view.jsx';
import AccNotificationsView from './acc-notifications-view.jsx'; import AccNotificationsView from './acc-notifications-view.jsx';
import AccSecurityView from './acc-security-view.jsx'; import AccSecurityView from './acc-security-view.jsx';
@@ -37,7 +37,7 @@ const messages = defineMessages({
}, },
edit: { edit: {
id: 'sidepanel_title_account_settings', id: 'sidepanel_title_account_settings',
description: 'Sidepanel title for EditAccountView.', description: 'Sidepanel title for AccountSettingsView.',
defaultMessage: 'Account Settings' defaultMessage: 'Account Settings'
}, },
general: { general: {
@@ -169,7 +169,7 @@ class SidepanelView extends React.Component {
onUpdate={this.props.onGlobalSettings} /> : onUpdate={this.props.onGlobalSettings} /> :
view === 'edit' ? view === 'edit' ?
<EditAccountView <AccountSettingsView
tinode={this.props.tinode} tinode={this.props.tinode}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
trustedBadges={this.props.trustedBadges} trustedBadges={this.props.trustedBadges}
+49 -177
View File
@@ -5,11 +5,10 @@ import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk'; import Tinode from 'tinode-sdk';
import AvatarCropView from './avatar-crop-view.jsx';
import AvatarUpload from '../widgets/avatar-upload.jsx'; import AvatarUpload from '../widgets/avatar-upload.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx'; import InPlaceEdit from '../widgets/in-place-edit.jsx';
import TagManager from '../widgets/tag-manager.jsx'; import TagManager from '../widgets/tag-manager.jsx';
import TopicDescEdit from '../widgets/topic-desc-edit.jsx';
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
MAX_INBAND_ATTACHMENT_SIZE, MAX_TITLE_LENGTH } from '../config.js'; MAX_INBAND_ATTACHMENT_SIZE, MAX_TITLE_LENGTH } from '../config.js';
@@ -21,126 +20,35 @@ export default class TopicCommonView extends React.Component {
super(props); super(props);
const topic = this.props.tinode.getTopic(this.props.topic); const topic = this.props.tinode.getTopic(this.props.topic);
const acs = topic.getAccessMode();
this.state = { this.state = {
fullName: topic.public ? topic.public.fn : undefined, isMe: this.props.tinode.isMe(this.props.topic),
avatar: makeImageUrl(topic.public ? topic.public.photo : null), owner: acs && acs.isOwner(),
tags: topic.tags(),
credentials: topic.getCredentials() || [], credentials: topic.getCredentials() || [],
addCredActive: false, addCredActive: false,
addCredInvalid: false, addCredInvalid: false
newCred: '',
newAvatar: null,
newAvatarMime: null,
previousOnTags: topic.onTagsUpdated
}; };
this.tnNewTags = this.tnNewTags.bind(this);
this.tnCredsUpdated = this.tnCredsUpdated.bind(this); this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handleImageUpdated = this.handleImageUpdated.bind(this);
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
this.uploadAvatar = this.uploadAvatar.bind(this);
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
this.handleCredChange = this.handleCredChange.bind(this); this.handleCredChange = this.handleCredChange.bind(this);
this.handleCredKeyDown = this.handleCredKeyDown.bind(this); this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
this.handleCredEntered = this.handleCredEntered.bind(this); this.handleCredEntered = this.handleCredEntered.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
} }
componentDidMount() { componentDidMount() {
const topic = this.props.tinode.getTopic(this.props.topic); const topic = this.props.tinode.getTopic(this.props.topic);
topic.onCredsUpdated = this.tnCredsUpdated; topic.onCredsUpdated = this.tnCredsUpdated;
topic.onTagsUpdated = this.tnNewTags;
} }
componentWillUnmount() { componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic); const topic = this.props.tinode.getTopic(this.props.topic);
topic.onTagsUpdated = this.state.previousOnTags;
topic.onCredsUpdated = undefined; topic.onCredsUpdated = undefined;
} }
tnNewTags(tags) {
this.setState({tags: tags});
}
tnCredsUpdated(creds) { tnCredsUpdated(creds) {
this.setState({credentials: creds || []}); this.setState({credentials: creds || []});
} }
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (fn) {
this.setState({fullName: fn});
this.props.onUpdateTopicDesc(this.props.topic, theCard(fn, null));
}
}
// AvatarUpload widget has changed the avatar: received a new file or deleted the avatar.
handleImageUpdated(mime, img) {
// This will show the AvatarCropView is img is not null.
this.setState({newAvatar: img, newAvatarMime: mime});
if (!img) {
// Deleting the avatar.
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, Tinode.DEL_CHAR));
}
}
// AvatarCropView calls this method when the user has cropped the image.
handleAvatarCropped(mime, blob, width, height) {
const url = blob ? URL.createObjectURL(blob) : null;
this.setState({avatar: url, newAvatar: null, newAvatarMime: null});
if (blob) {
this.uploadAvatar(mime, blob, width, height);
}
}
// Utility method for converting cropped avatar blob to bytes for sending inband or
// for uploading it to the server out of band.
uploadAvatar(mime, blob, width, height) {
const readyToUpload = (mime, blob, width, height) => {
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
this.setState({uploading: true});
uploader.upload(blob)
.then((url) => {
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url));
})
.catch((err) => {
this.props.onError(err, 'err');
})
.finally(() => {
this.setState({uploading: false});
});
} else {
this.setState({uploading: true});
// Convert blob to base64-encoded bits.
blobToBase64(blob, (unused, base64bits) => {
const du = makeImageUrl({data: base64bits, type: mime});
this.setState({source: du});
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, du));
this.setState({uploading: false});
});
}
};
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
// Avatar is not square or too large even after cropping. Shrink it and make square.
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
readyToUpload,
(err) => {
this.props.onError(err, 'err');
});
} else {
readyToUpload(mime, blob, width, height);
}
}
handleAvatarCropCancel(img) {
this.setState({newAvatar: null, newAvatarMime: null});
}
handleCredChange(e) { handleCredChange(e) {
this.setState({newCred: e.target.value, addCredInvalid: false}); this.setState({newCred: e.target.value, addCredInvalid: false});
} }
@@ -180,91 +88,55 @@ export default class TopicCommonView extends React.Component {
} }
} }
handleTagsUpdated(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
}
render() { render() {
if (this.state.newAvatar) {
return (
<AvatarCropView
avatar={this.state.newAvatar}
mime={this.state.newAvatarMime}
onSubmit={this.handleAvatarCropped}
onCancel={this.handleAvatarCropCancel}
onError={this.props.onError} />
);
}
const credentials = []; const credentials = [];
this.state.credentials.map((cred) => { if (this.state.isMe) {
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt> this.state.credentials.map((cred) => {
<span > {!cred.done ? credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}> <span > {!cred.done ?
<FormattedMessage id="validate_credential_action" defaultMessage="confirm" <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
description="Validate credentail call to action" /> <FormattedMessage id="validate_credential_action" defaultMessage="confirm"
</a> description="Validate credentail call to action" />
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i </a>
className="material-icons gray">delete_outline</i></a></span></div>); : null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
}); className="material-icons gray">delete_outline</i></a></span></div>);
});
}
return ( return (
<div className="scrollable-panel"> <div className="scrollable-panel">
<div className="panel-form-row"> <TopicDescEdit
<div className="panel-form-column"> tinode={this.props.tinode}
<label className="small"><FormattedMessage id="label_your_name" defaultMessage="Your name" topic={this.props.topic}
description="Label for full name editing" /></label> onUpdateTopicDesc={this.props.onUpdateTopicDesc}
<div><FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe" onUpdateTags={this.props.onUpdateTags}
description="Input placeholder for person's full name">{ onError={this.props.onError} />
(full_name_placeholder) => <InPlaceEdit {this.state.isMe ?
placeholder={full_name_placeholder} <>
value={this.state.fullName} <div className="hr" />
onFinished={this.handleFullNameUpdate} /> <div className="panel-form-column">
}</FormattedMessage></div> <label className="small">
</div> <FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
<AvatarUpload description="Label for user contacts" />
tinode={this.props.tinode} </label>
avatar={this.state.avatar} <div className="quoted">
uid={this.props.myUserId} {credentials}
title={this.state.fullName} {this.state.addCredActive ?
onImageUpdated={this.handleImageUpdated} <input type="text" value={this.state.value}
onError={this.props.onError} /> className={this.state.addCredInvalid ? 'invalid' : null}
</div> placeholder="Phone number or email" required="required" autoFocus
<div className="hr" /> onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)" : null}
description="Section title for TagManager">{ <div>
(title_tag_manager) => <TagManager <a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
title={title_tag_manager} <i className="material-icons">add</i>
activated={false} <FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
tags={this.state.tags} </a></div>
tinode={this.props.tinode} </div>
onSubmit={this.handleTagsUpdated} /> </div>
}</FormattedMessage> </>
<div className="hr" /> : null
<div className="panel-form-column"> }
<label className="small">
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
description="Label for user contacts" />
</label>
<div className="quoted">
{credentials}
{this.state.addCredActive ?
<input type="text" value={this.state.value}
className={this.state.addCredInvalid ? 'invalid' : null}
placeholder="Phone number or email" required="required" autoFocus
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
: null}
<div>
<a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
<i className="material-icons">add</i>
<FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
</a></div>
</div>
</div>
</div> </div>
); );
} }
@@ -2,10 +2,10 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Cropper from '../widgets/cropper.jsx'; import Cropper from './cropper.jsx';
import { imageCrop } from '../lib/blob-helpers.js'; import { imageCrop } from '../lib/blob-helpers.js';
export default class AvatarCropView extends React.PureComponent { export default class AvatarCrop extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
+226
View File
@@ -0,0 +1,226 @@
// Widget for editing topic description.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import AvatarCrop from './avatar-crop.jsx';
import AvatarUpload from './avatar-upload.jsx';
import InPlaceEdit from './in-place-edit.jsx';
import TagManager from './tag-manager.jsx';
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
MAX_INBAND_ATTACHMENT_SIZE, MAX_TITLE_LENGTH } from '../config.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, theCard } from '../lib/utils.js';
export default class TopicDescEdit extends React.Component {
constructor(props) {
super(props);
const topic = this.props.tinode.getTopic(this.props.topic);
const acs = topic.getAccessMode();
this.state = {
isMe: this.props.tinode.isMe(this.props.topic),
owner: acs && acs.isOwner(),
fullName: topic.public ? topic.public.fn : undefined,
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
tags: topic.tags(),
newAvatar: null,
newAvatarMime: null,
previousOnTags: topic.onTagsUpdated
};
this.tnNewTags = this.tnNewTags.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handleImageUpdated = this.handleImageUpdated.bind(this);
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
this.uploadAvatar = this.uploadAvatar.bind(this);
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
}
componentDidMount() {
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onTagsUpdated = this.tnNewTags;
}
componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onTagsUpdated = this.state.previousOnTags;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (fn) {
this.setState({fullName: fn});
this.props.onUpdateTopicDesc(this.props.topic, theCard(fn, null));
}
}
// AvatarUpload widget has changed the avatar: received a new file or deleted the avatar.
handleImageUpdated(mime, img) {
// This will show the AvatarCropView is img is not null.
this.setState({newAvatar: img, newAvatarMime: mime});
if (!img) {
// Deleting the avatar.
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, Tinode.DEL_CHAR));
}
}
// AvatarCropView calls this method when the user has cropped the image.
handleAvatarCropped(mime, blob, width, height) {
const url = blob ? URL.createObjectURL(blob) : null;
this.setState({avatar: url, newAvatar: null, newAvatarMime: null});
if (blob) {
this.uploadAvatar(mime, blob, width, height);
}
}
// Utility method for converting cropped avatar blob to bytes for sending inband or
// for uploading it to the server out of band.
uploadAvatar(mime, blob, width, height) {
const readyToUpload = (mime, blob, width, height) => {
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
this.setState({uploading: true});
uploader.upload(blob)
.then((url) => {
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url));
})
.catch((err) => {
this.props.onError(err, 'err');
})
.finally(() => {
this.setState({uploading: false});
});
} else {
this.setState({uploading: true});
// Convert blob to base64-encoded bits.
blobToBase64(blob, (unused, base64bits) => {
const du = makeImageUrl({data: base64bits, type: mime});
this.setState({source: du});
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, du));
this.setState({uploading: false});
});
}
};
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
// Avatar is not square or too large even after cropping. Shrink it and make square.
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
readyToUpload,
(err) => {
this.props.onError(err, 'err');
});
} else {
readyToUpload(mime, blob, width, height);
}
}
handleAvatarCropCancel(img) {
this.setState({newAvatar: null, newAvatarMime: null});
}
handleTagsUpdated(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
}
render() {
if (this.state.newAvatar) {
return (
<AvatarCrop
avatar={this.state.newAvatar}
mime={this.state.newAvatarMime}
onSubmit={this.handleAvatarCropped}
onCancel={this.handleAvatarCropCancel}
onError={this.props.onError} />
);
}
const editable = this.state.isMe || this.state.owner;
return (
<>
<div className="panel-form-row">
<div className="panel-form-column">
{this.state.me ? <>
<label className="small">
<FormattedMessage id="label_your_name" defaultMessage="Your name"
description="Label for full name editing" />
</label>
<div><FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
description="Input placeholder for person's full name">{
(full_name_placeholder) => <InPlaceEdit
placeholder={full_name_placeholder}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage></div>
</> : <>
<div><label className="small">
<FormattedMessage id="label_topic_name" defaultMessage="Name"
description="Label for editing topic name" />
</label></div>
<div><InPlaceEdit
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
readOnly={!this.state.owner}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} /></div>
<div><label className="small">
<FormattedMessage id="label_private" defaultMessage="Private comment"
description="Label for editing 'private'" />
</label></div>
<div>
<FormattedMessage id="private_editing_placeholder"
defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{
(private_placeholder) => <InPlaceEdit
placeholder={private_placeholder}
value={this.state.private}
onFinished={this.handlePrivateUpdate} />
}</FormattedMessage>
</div>
</>
}
</div>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.avatar}
readOnly={!editable}
uid={this.props.topic}
title={this.state.fullName}
onImageUpdated={this.handleImageUpdated}
onError={this.props.onError} />
</div>
{editable ?
<>
<div className="hr" />
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
description="Section title for TagManager">{
(title_tag_manager) => <TagManager
title={title_tag_manager}
activated={false}
tags={this.state.tags}
tinode={this.props.tinode}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
</>
: null
}
</>
);
}
};