chore: seed migration objects batch 2

This commit is contained in:
dev
2026-10-18 13:59:32 +08:00
parent 3e00c3422d
commit 365e3284c2
47 changed files with 6331 additions and 3128 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit. // This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.17.10"; export const PACKAGE_VERSION = "0.18.0-beta1";
-189
View File
@@ -1,189 +0,0 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { MAX_TITLE_LENGTH } from '../config.js';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { arrayEqual, asEmail, asPhone, theCard } from '../lib/utils.js';
export default class AccGeneralView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageDataUrl(me.public ? me.public.photo : null),
tags: me.tags(),
credentials: me.getCredentials() || [],
addCredActive: false,
addCredInvalid: false,
newCred: '',
previousOnTags: me.onTagsUpdated
};
this.tnNewTags = this.tnNewTags.bind(this);
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleCredChange = this.handleCredChange.bind(this);
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
this.handleCredEntered = this.handleCredEntered.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
}
componentDidMount() {
const me = this.props.tinode.getMeTopic();
me.onCredsUpdated = this.tnCredsUpdated;
me.onTagsUpdated = this.tnNewTags;
}
componentWillUnmount() {
const me = this.props.tinode.getMeTopic();
me.onTagsUpdated = this.state.previousOnTags;
me.onCredsUpdated = undefined;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
tnCredsUpdated(creds) {
this.setState({credentials: creds || []});
}
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (fn) {
this.setState({fullName: fn});
this.props.onUpdateAccount(undefined, theCard(fn, null));
}
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onUpdateAccount(undefined, theCard(null, img || Tinode.DEL_CHAR));
}
handleCredChange(e) {
this.setState({newCred: e.target.value, addCredInvalid: false});
}
handleCredKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({newCred: '', addCredActive: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleCredEntered(e);
}
}
handleCredEntered(e) {
let value = this.state.newCred.trim();
if (!value) {
this.setState({addCredActive: false, addCredInvalid: false});
return;
}
let val = asPhone(value);
let method;
if (val) {
method = 'tel';
} else {
val = asEmail(value);
if (val) {
method = 'email';
}
}
if (method) {
this.props.onCredAdd(method, val);
this.setState({addCredActive: false, newCred: ''});
} else {
this.setState({addCredInvalid: true});
}
}
handleTagsUpdated(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
}
render() {
const credentials = [];
this.state.credentials.map((cred) => {
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
<span > {!cred.done ?
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
description="Validate credentail call to action" />
</a>
: 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 (
<div className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<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}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage></div>
</div>
<AvatarUpload
avatar={this.state.avatar}
uid={this.props.myUserId}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<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>
<div className="hr" />
<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>
);
}
};
+5 -5
View File
@@ -12,7 +12,7 @@ const messages = defineMessages({
description: 'Title for delete account warning' description: 'Title for delete account warning'
}, },
delete_account_warning: { delete_account_warning: {
id: 'delete_account_arning', id: 'delete_account_warning',
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.', defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
description: 'Warning message when deleting an account' description: 'Warning message when deleting an account'
} }
@@ -46,7 +46,7 @@ class AccSecurityView extends React.Component {
handlePasswordUpdate(pwd) { handlePasswordUpdate(pwd) {
this.setState({password: pwd}); this.setState({password: pwd});
this.props.onUpdateAccount(pwd); this.props.onUpdatePassword(pwd);
} }
handleLaunchPermissionsEditor(which) { handleLaunchPermissionsEditor(which) {
@@ -63,7 +63,7 @@ class AccSecurityView extends React.Component {
handlePermissionsChanged(perm) { handlePermissionsChanged(perm) {
let defacs = {}; let defacs = {};
defacs[this.state.showPermissionEditorFor] = perm; defacs[this.state.showPermissionEditorFor] = perm;
this.props.onUpdateAccount(undefined, undefined, defacs); this.props.onUpdateAccountDesc('me', undefined, undefined, defacs);
let newState = {showPermissionEditorFor: undefined}; let newState = {showPermissionEditorFor: undefined};
newState[this.state.showPermissionEditorFor] = perm; newState[this.state.showPermissionEditorFor] = perm;
@@ -85,7 +85,7 @@ class AccSecurityView extends React.Component {
render() { render() {
return ( return (
<React.Fragment>{this.state.showPermissionEditorFor ? <>{this.state.showPermissionEditorFor ?
<PermissionsEditor <PermissionsEditor
mode={this.state.editedPermissions} mode={this.state.editedPermissions}
skip="O" skip="O"
@@ -147,7 +147,7 @@ class AccSecurityView extends React.Component {
</> </>
: null } : null }
</div> </div>
}</React.Fragment> }</>
); );
} }
}; };
+85
View File
@@ -0,0 +1,85 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
function _clip(str, length) {
return str && str.substring(0, length);
}
export default class AccountSettingsView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: _clip(me.public ? me.public.fn : undefined, MAX_TITLE_LENGTH),
description: _clip(me.public ? me.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
avatar: makeImageUrl(me.public ? me.public.photo : null)
};
}
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
<center>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.avatar}
readOnly={true}
uid={this.props.myUserId}
title={this.state.fullName} />
</center>
<div className="group">
<label className="small">
<FormattedMessage id="label_your_name" defaultMessage="Your name"
description="Label for full name editing" />
</label>
<div className="large ellipsized">{this.state.fullName}</div>
</div>
<div className="group">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.props.myUserId}</tt>
</div>
<div className="group">
<BadgeList trustedBadges={this.props.trustedBadges} />
</div>
{this.state.description ?
<div className="group">
<label className="small">
<FormattedMessage id="label_description" defaultMessage="Description"
description="Label for editing topic description" />
</label>
<div>{this.state.description}</div>
</div> : null}
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('notif');}}>
<i className="material-icons">notifications</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_notifications"
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
<i className="material-icons">security</i>&nbsp;<FormattedMessage id="button_security"
defaultMessage="Security" description="Navigaton button for security panel." />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('support');}}>
<i className="material-icons">contact_support</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_support"
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
</a>
</div>
</div>
);
}
};
+1
View File
@@ -89,6 +89,7 @@ export default class ContactsView extends React.Component {
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯" defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
description="HTML message shown in ContactList when no contacts are found">{ description="HTML message shown in ContactList when no contacts are found">{
(no_contacts) => <ContactList (no_contacts) => <ContactList
tinode={this.props.tinode}
connected={this.props.connected} connected={this.props.connected}
contacts={this.state.contactList} contacts={this.state.contactList}
emptyListMessage={no_contacts} emptyListMessage={no_contacts}
+1
View File
@@ -92,6 +92,7 @@ export default class CreateAccountView extends React.PureComponent {
}</FormattedMessage> }</FormattedMessage>
</div> </div>
<AvatarUpload <AvatarUpload
tinode={this.props.tinode}
onImageChanged={this.handleImageChanged} onImageChanged={this.handleImageChanged}
onError={this.props.onError} /> onError={this.props.onError} />
</div> </div>
-66
View File
@@ -1,66 +0,0 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
export default class EditAccountView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageDataUrl(me.public ? me.public.photo : null)
};
}
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<label className="small">
<FormattedMessage id="label_your_name" defaultMessage="Your name"
description="Label for full name editing" />
</label>
<div className="large">{this.state.fullName}</div>
<div>
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.props.myUserId}</tt>
</div>
</div>
<AvatarUpload
avatar={this.state.avatar}
readOnly={!this.state.owner}
uid={this.props.myUserId}
title={this.state.fullName} />
</div>
<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 className="hr" />
<div className="panel-form-column">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('notif');}}>
<i className="material-icons">notifications</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_notifications"
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('security');}}>
<i className="material-icons">security</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_security"
defaultMessage="Security" description="Sidepanel title for AccSecurityView." />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('support');}}>
<i className="material-icons">contact_support</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_support"
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
</a>
</div>
</div>
);
}
};
+225 -398
View File
@@ -5,104 +5,83 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk'; import Tinode from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx'; import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
import CheckBox from '../widgets/checkbox.jsx'; import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx'; import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx'; import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx'; import GroupManager from '../widgets/group-manager.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import MenuCancel from '../widgets/menu-cancel.jsx'; import MenuCancel from '../widgets/menu-cancel.jsx';
import MoreButton from '../widgets/more-button.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx'; import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TagManager from '../widgets/tag-manager.jsx'; import TopicCommon from '../widgets/topic-common.jsx';
import TopicSecurity from '../widgets/topic-security.jsx';
import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js'; import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
import { makeImageDataUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, theCard } from '../lib/utils.js'; import { arrayEqual, theCard } from '../lib/utils.js';
const messages = defineMessages({ const messages = defineMessages({
requested: { info: {
id: 'panel_title_info',
description: 'Title for InfoView',
defaultMessage: 'Info'
},
general: {
id: 'panel_title_general',
description: 'Title for TopicCommon.',
defaultMessage: 'General'
},
security: {
id: 'panel_title_security',
description: 'Title for TopicSecirity and AccSecurity.',
defaultMessage: 'Security'
},
members: {
id: 'panel_title_members',
description: 'Title for managing group members view.',
defaultMessage: 'Members'
},
crop: {
id: 'panel_title_crop',
description: 'Title for AvatarCropView.',
defaultMessage: 'Drag to Adjust'
},
perm_want: {
id: 'requested_permissions', id: 'requested_permissions',
defaultMessage: 'Requested', defaultMessage: 'Requested',
description: 'Title for permissions' description: 'Title for permissions'
}, },
granted: { perm_given: {
id: 'granted_permissions', id: 'granted_permissions',
defaultMessage: 'Granted', defaultMessage: 'Granted',
description: 'Title for permissions' description: 'Title for permissions'
}, },
perm_auth: {
id: 'permissions_authenticated',
defaultMessage: 'Authenticated',
description: 'Title for editing default authenticated permissions'
},
perm_anon: {
id: 'permissions_anonymous',
defaultMessage: 'Anonymous',
description: 'Title for editing default anonymous permissions'
},
perm_user: {
id: 'permissions_user',
defaultMessage: 'User\'s Permissions',
description: 'Title for editing user\'s permissions'
},
edit_permissions: { edit_permissions: {
id: 'menu_item_edit_permissions', id: 'menu_item_edit_permissions',
defaultMessage: 'Edit permissions', defaultMessage: 'Edit permissions',
description: 'Menu item [Edit permissions]' description: 'Menu item [Edit permissions]'
}, },
other_user: {
id: 'label_other_user',
defaultMessage: 'Other',
description: 'Label for the other user when the user is unnamed'
},
clear_messages: {
id: 'action_clear_messages',
defaultMessage: 'Clear Messages',
description: 'Flat button [Clear Messages] (soft-delete messages)'
},
clear_messages_warning: {
id: 'clear_messages_warning',
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
description: 'Alert dialog warning when deleting all messages.'
},
delete_messages: {
id: 'action_delete_messages',
defaultMessage: 'Clear Messages for All',
description: 'Flat button [Clear for All] (hard-delete all messages)'
},
delete_messages_warning: {
id: 'delete_messages_warning',
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
description: 'Alert dialog warning when hard-deleting all messages.'
},
topic_delete: {
id: 'topic_delete',
defaultMessage: 'Delete Conversation',
description: 'Alert title when deleting the topic.'
},
topic_delete_warning: {
id: 'topic_delete_warning',
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
description: 'Alert warning when deleting entire topic'
},
leave_chat: {
id: 'action_leave_chat',
defaultMessage: 'Leave Conversation',
description: 'Flat button [Leave Conversation]'
},
leave_chat_warning: {
id: 'leave_chat_warning',
defaultMessage: 'Are you sure you want to leave this conversation?',
description: 'Alert dialog warning when unsubscribing from a chat.'
},
block_contact: {
id: 'action_block_contact',
defaultMessage: "Block Contact",
description: "Flat button [Block Contact]"
},
block_contact_warning: {
id: 'block_contact_warning',
defaultMessage: 'Are you sure you want to block this contact?',
description: 'Alert dialog warning when blocking a contact.'
},
report_chat: {
id: 'action_report_chat',
defaultMessage: 'Report Conversation',
description: 'Flat button [Report Group]'
},
report_chat_warning: {
id: 'report_chat_warning',
defaultMessage: 'Are you sure you want to block and report this conversation?',
description: 'Alert dialog warning when reporting a conversation for abuse'
},
}); });
function _clip(str, length) {
return str && str.substring(0, length);
}
class InfoView extends React.Component { class InfoView extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -118,6 +97,7 @@ class InfoView extends React.Component {
groupTopic: undefined, groupTopic: undefined,
channel: undefined, channel: undefined,
fullName: undefined, fullName: undefined,
description: undefined,
avatar: null, avatar: null,
private: null, private: null,
selectedContact: null, selectedContact: null,
@@ -129,39 +109,26 @@ class InfoView extends React.Component {
auth: null, auth: null,
anon: null, anon: null,
contactList: [], contactList: [],
tags: [], trustedBadges: [],
showMemberPanel: false,
showPermissionEditorFor: undefined,
moreInfoExpanded: false,
previousMetaDesc: undefined, previousMetaDesc: undefined,
previousSubsUpdated: undefined, previousSubsUpdated: undefined,
previousTagsUpdated: undefined
}; };
this.resetSubs = this.resetSubs.bind(this); this.resetSubs = this.resetSubs.bind(this);
this.resetDesc = this.resetDesc.bind(this); this.resetDesc = this.resetDesc.bind(this);
this.resetTags = this.resetTags.bind(this);
this.onMetaDesc = this.onMetaDesc.bind(this); this.onMetaDesc = this.onMetaDesc.bind(this);
this.onSubsUpdated = this.onSubsUpdated.bind(this); this.onSubsUpdated = this.onSubsUpdated.bind(this);
this.onTagsUpdated = this.onTagsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this); this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleMuted = this.handleMuted.bind(this); this.handleMuted = this.handleMuted.bind(this);
this.handleUnarchive = this.handleUnarchive.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this); this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this); this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handleShowAddMembers = this.handleShowAddMembers.bind(this); this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this); this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleDeleteTopic = this.handleDeleteTopic.bind(this);
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
this.handleLeave = this.handleLeave.bind(this);
this.handleBlock = this.handleBlock.bind(this);
this.handleReport = this.handleReport.bind(this);
this.handleMemberSelected = this.handleMemberSelected.bind(this); this.handleMemberSelected = this.handleMemberSelected.bind(this);
this.handleMoreInfo = this.handleMoreInfo.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this); this.handleContextMenu = this.handleContextMenu.bind(this);
this.handleBackNavigate = this.handleBackNavigate.bind(this);
} }
// No need to separately handle component mount. // No need to separately handle component mount.
@@ -177,19 +144,13 @@ class InfoView extends React.Component {
this.previousSubsUpdated = topic.onSubsUpdated; this.previousSubsUpdated = topic.onSubsUpdated;
topic.onSubsUpdated = this.onSubsUpdated; topic.onSubsUpdated = this.onSubsUpdated;
if (topic.getType() == 'grp') {
this.previousTagsUpdated = topic.onTagsUpdated;
topic.onTagsUpdated = this.onTagsUpdated;
} else {
this.previousTagsUpdated = undefined;
}
} }
if (this.state.topic != props.topic) { if (this.state.topic != props.topic) {
this.setState({topic: props.topic}); this.setState({topic: props.topic});
this.resetDesc(topic, props); this.resetDesc(topic, props);
this.resetSubs(topic, props); this.resetSubs(topic, props);
this.resetTags(topic);
} }
} }
@@ -201,7 +162,6 @@ class InfoView extends React.Component {
this.setState({topic: null}); this.setState({topic: null});
topic.onMetaDesc = this.previousMetaDesc; topic.onMetaDesc = this.previousMetaDesc;
topic.onSubsUpdated = this.previousSubsUpdated; topic.onSubsUpdated = this.previousSubsUpdated;
topic.onTagsUpdated = this.previousTagsUpdated;
} }
resetSubs(topic, props) { resetSubs(topic, props) {
@@ -230,6 +190,15 @@ class InfoView extends React.Component {
const defacs = topic.getDefaultAccess() || {}; const defacs = topic.getDefaultAccess() || {};
const acs = topic.getAccessMode(); const acs = topic.getAccessMode();
const badges = [];
if (topic.trusted) {
for (const [key, val] of Object.entries(topic.trusted)) {
if (val) {
badges.push(key);
}
}
}
this.setState({ this.setState({
owner: acs && acs.isOwner(), owner: acs && acs.isOwner(),
admin: acs && acs.isAdmin(), admin: acs && acs.isAdmin(),
@@ -237,21 +206,30 @@ class InfoView extends React.Component {
deleter: acs && acs.isDeleter(), deleter: acs && acs.isDeleter(),
muted: acs && acs.isMuted(), muted: acs && acs.isMuted(),
fullName: topic.public ? topic.public.fn : undefined, fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
avatar: makeImageDataUrl(topic.public ? topic.public.photo : null), description: _clip(topic.public ? topic.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
private: topic.private ? topic.private.comment : null, avatar: makeImageUrl(topic.public ? topic.public.photo : null),
trustedBadges: badges,
private: _clip(topic.private ? topic.private.comment : null, MAX_TITLE_LENGTH),
archived: topic.isArchived(),
address: topic.name, address: topic.name,
groupTopic: topic.isGroupType(), groupTopic: topic.isGroupType(),
channel: topic.isChannelType(), channel: topic.isChannelType() || topic.chan,
showMemberPanel: false,
access: acs ? acs.getMode() : undefined, access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined, modeGiven: acs ? acs.getGiven() : undefined,
modeWant: acs ? acs.getWant() : undefined, modeWant: acs ? acs.getWant() : undefined,
auth: defacs.auth, auth: defacs.auth,
anon: defacs.anon anon: defacs.anon
}); });
}
if (topic.getType() == 'grp' && acs && acs.isOwner()) { resetTags(topic) {
if (topic.getType() != 'grp') {
return;
}
const acs = topic.getAccessMode();
if (acs && acs.isOwner()) {
// Requesting tags: owner is editing the topic. // Requesting tags: owner is editing the topic.
topic.getMeta(topic.startMetaQuery().withTags().build()); topic.getMeta(topic.startMetaQuery().withTags().build());
} }
@@ -281,30 +259,6 @@ class InfoView extends React.Component {
} }
} }
onTagsUpdated(tags) {
this.setState({tags: tags});
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
this.previousTagsUpdated();
}
}
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (this.state.fullName !== fn) {
this.setState({fullName: fn});
this.props.onTopicDescUpdate(this.props.topic, theCard(fn, null), null);
}
}
handlePrivateUpdate(comment) {
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
if (this.state.private !== comment) {
this.setState({private: comment});
this.props.onTopicDescUpdate(this.props.topic, null, comment || Tinode.DEL_CHAR);
}
}
handleImageChanged(img) { handleImageChanged(img) {
this.setState({avatar: img}); this.setState({avatar: img});
this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null); this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null);
@@ -315,13 +269,17 @@ class InfoView extends React.Component {
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P'); this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
} }
handlePermissionsChanged(perm) { handleUnarchive(ignored, ignored2) {
switch (this.state.showPermissionEditorFor) { this.props.onTopicUnArchive(this.props.topic);
}
handlePermissionsChanged(which, perm) {
switch (which) {
case 'auth': case 'auth':
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm}); this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {auth: perm});
break; break;
case 'anon': case 'anon':
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm}); this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {anon: perm});
break; break;
case 'mode': case 'mode':
case 'want': case 'want':
@@ -335,7 +293,7 @@ class InfoView extends React.Component {
break; break;
} }
this.setState({showPermissionEditorFor: undefined}); this.handleBackNavigate();
} }
handleLaunchPermissionsEditor(which, uid) { handleLaunchPermissionsEditor(which, uid) {
@@ -359,15 +317,15 @@ class InfoView extends React.Component {
toSkip += 'W'; toSkip += 'W';
} }
} }
titleEdit = formatMessage(messages.requested); titleEdit = formatMessage(messages.perm_want);
titleCompare = formatMessage(messages.granted); titleCompare = formatMessage(messages.perm_given);
break; break;
case 'given': case 'given':
toEdit = this.state.modeGiven2; toEdit = this.state.modeGiven2;
toCompare = this.state.modeWant2; toCompare = this.state.modeWant2;
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO'; toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
titleEdit = formatMessage(messages.granted); titleEdit = formatMessage(messages.perm_given);
titleCompare = formatMessage(messages.requested); titleCompare = formatMessage(messages.perm_want);
break; break;
case 'auth': case 'auth':
toEdit = this.state.auth; toEdit = this.state.auth;
@@ -389,8 +347,8 @@ class InfoView extends React.Component {
toEdit = user.acs.getGiven(); toEdit = user.acs.getGiven();
toCompare = user.acs.getWant(); toCompare = user.acs.getWant();
toSkip = this.state.owner ? '' : 'O'; toSkip = this.state.owner ? '' : 'O';
titleEdit = formatMessage(messages.granted); titleEdit = formatMessage(messages.perm_given);
titleCompare = formatMessage(messages.requested); titleCompare = formatMessage(messages.perm_want);
if (user.public) { if (user.public) {
userTitle = user.public.fn; userTitle = user.public.fn;
userAvatar = user.public.photo; userAvatar = user.public.photo;
@@ -398,11 +356,10 @@ class InfoView extends React.Component {
break; break;
} }
default: default:
console.log("Unknown permission editing mode '" + which + "'"); console.error("Unknown permission editing mode '" + which + "'");
break; return;
} }
this.setState({ this.setState({
showPermissionEditorFor: which,
userPermissionsEdited: uid, userPermissionsEdited: uid,
userPermissionsTitle: userTitle, userPermissionsTitle: userTitle,
userPermissionsAvatar: userAvatar, userPermissionsAvatar: userAvatar,
@@ -412,109 +369,41 @@ class InfoView extends React.Component {
immutablePermissionsTitle: titleCompare, immutablePermissionsTitle: titleCompare,
editedPermissionsSkipped: toSkip, editedPermissionsSkipped: toSkip,
}); });
} this.props.onNavigate(`perm/${which}`);
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
} }
handleShowAddMembers(e) { handleShowAddMembers(e) {
e.preventDefault(); e.preventDefault();
this.props.onInitFind(); this.props.onInitFind();
this.setState({showMemberPanel: true}); this.props.onNavigate('members');
}
handleHideAddMembers() {
this.setState({showMemberPanel: false});
} }
handleMemberUpdateRequest(members, added, removed) { handleMemberUpdateRequest(members, added, removed) {
this.props.onMemberUpdateRequest(this.props.topic, added, removed); this.props.onMemberUpdateRequest(this.props.topic, added, removed);
this.setState({showMemberPanel: false}); this.props.onNavigate('info');
}
handleDeleteTopic(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.topic_delete), // title
formatMessage(messages.topic_delete_warning), // content
(() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleDeleteMessages(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages), // title
formatMessage(this.state.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleLeave(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.leave_chat), // title
formatMessage(messages.leave_chat_warning), // content
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleBlock(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.block_contact), // title
formatMessage(messages.block_contact_warning), // content
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleReport(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.report_chat), // title
formatMessage(messages.report_chat_warning), // content
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
} }
handleMemberSelected(uid) { handleMemberSelected(uid) {
this.setState({selectedContact: uid}); this.setState({selectedContact: uid});
} }
handleMoreInfo(open) { handleBackNavigate() {
this.setState({moreInfoExpanded: open}); const args = (this.props.panel || 'info').split('/');
if (args[0] == 'info') {
this.props.onNavigate(null);
} else if (args[0] == 'perm') {
if (args[1] == 'user') {
this.props.onNavigate('info');
} else {
this.props.onNavigate('security');
} }
} else {
handleTagsUpdated(tags) { this.props.onNavigate('info');
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
this.props.onTopicTagsUpdate(this.props.topic, tags);
} }
} }
handleContextMenu(params) { handleContextMenu(params) {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
const instance = this;
const topic = this.props.tinode.getTopic(this.props.topic); const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) { if (!topic) {
return; return;
@@ -524,14 +413,19 @@ class InfoView extends React.Component {
return; return;
} }
const isMe = this.props.tinode.isMe(params.topicName);
const menuItems = [ const menuItems = [
{title: formatMessage(messages.edit_permissions), handler: function() { {title: formatMessage(messages.edit_permissions), handler: () => {
instance.handleLaunchPermissionsEditor('user', params.topicName); this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName);
}}, }}
'member_delete',
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
]; ];
if (!isMe) {
menuItems.push('member_delete');
}
menuItems.push(user.acs.isMuted() ? 'member_unmute' : 'member_mute');
if (!isMe) {
menuItems.push(user.acs.isJoiner() ? 'member_block' : 'member_unblock');
}
this.props.showContextMenu({ this.props.showContextMenu({
topicName: this.props.topic, topicName: this.props.topic,
x: params.x, x: params.x,
@@ -540,16 +434,20 @@ class InfoView extends React.Component {
} }
render() { render() {
const args = (this.props.panel || 'info').split('/');
const view = args[0];
args.shift();
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view])
|| messages['info']);
return ( return (
<div id="info-view"> <div id="info-view">
<div className="caption-panel" id="info-caption-panel"> <div className="caption-panel" id="info-caption-panel">
<div className="panel-title" id="info-title"> <div className="panel-title" id="info-title">{panelTitle}</div>
<FormattedMessage id="title_info" defaultMessage="Info" description="Title for InfoView" />
</div>
<div> <div>
<MenuCancel onCancel={this.props.onCancel} /> <MenuCancel onCancel={this.handleBackNavigate} />
</div> </div>
</div> </div>
{this.props.displayMobile ? {this.props.displayMobile ?
@@ -557,18 +455,20 @@ class InfoView extends React.Component {
level={this.props.errorLevel} level={this.props.errorLevel}
text={this.props.errorText} text={this.props.errorText}
onClearError={this.props.onError} /> : null} onClearError={this.props.onError} /> : null}
{this.state.showMemberPanel ? {view == 'members' ?
<GroupManager <GroupManager
tinode={this.props.tinode}
members={this.state.contactList} members={this.state.contactList}
requiredMember={this.props.myUserId} requiredMember={this.props.myUserId}
keepInitialMembers={!this.state.admin && !this.state.owner} keepInitialMembers={!this.state.admin && !this.state.owner}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
contacts={this.props.searchableContacts} contacts={this.props.searchableContacts}
onCancel={this.handleHideAddMembers} onCancel={this.handleBackNavigate}
onSubmit={this.handleMemberUpdateRequest} /> onSubmit={this.handleMemberUpdateRequest} />
: :
this.state.showPermissionEditorFor ? view == 'perm' && args.length > 0 ?
<PermissionsEditor <PermissionsEditor
tinode={this.props.tinode}
mode={this.state.editedPermissions} mode={this.state.editedPermissions}
compare={this.state.immutablePermissions} compare={this.state.immutablePermissions}
skip={this.state.editedPermissionsSkipped} skip={this.state.editedPermissionsSkipped}
@@ -577,198 +477,125 @@ class InfoView extends React.Component {
userTitle={this.state.userPermissionsTitle} userTitle={this.state.userPermissionsTitle}
item={this.state.userPermissionsEdited} item={this.state.userPermissionsEdited}
userAvatar={this.state.userPermissionsAvatar} userAvatar={this.state.userPermissionsAvatar}
onSubmit={this.handlePermissionsChanged} onSubmit={(mode) => this.handlePermissionsChanged(args[0], mode)}
onCancel={this.handleHidePermissionsEditor} onCancel={this.handleBackNavigate} />
/> :
view == 'general' ?
<TopicCommon
tinode={this.props.tinode}
topic={this.props.topic}
onCredAdd={this.props.onCredAdd}
onTopicTagsUpdateRequest={this.props.onTopicTagsUpdateRequest}
onCredConfirm={this.props.onCredConfirm}
onCredDelete={this.props.onCredDelete}
onUpdateTopicDesc={this.props.onTopicDescUpdateRequest}
onError={this.props.onError} />
:
view == 'security' ?
<TopicSecurity
topic={this.props.topic}
owner={this.state.owner}
admin={this.state.admin}
sharer={this.state.sharer}
deleter={this.state.deleter}
muted={this.state.muted}
groupTopic={this.state.groupTopic}
channel={this.state.channel}
access={this.state.access}
modeGiven={this.state.modeGiven}
modeWant={this.state.modeWant}
modeGiven2={this.state.modeGiven2}
modeWant2={this.state.modeWant2}
auth={this.state.auth}
anon={this.state.anon}
onShowAlert={this.props.onShowAlert}
onDeleteMessages={this.props.onDeleteMessages}
onLeaveTopic={this.props.onLeaveTopic}
onBlockTopic={this.props.onBlockTopic}
onReportTopic={this.props.onReportTopic}
onLaunchPermissionsEditor={this.handleLaunchPermissionsEditor}
onNavigate={this.props.onNavigate} />
: :
<div id="info-view-content" className="scrollable-panel"> <div id="info-view-content" className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column"> <div className="panel-form-column">
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
<center>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.avatar}
readOnly={true}
uid={this.props.topic}
title={this.state.fullName} />
</center>
<div className="group">
<div><label className="small"> <div><label className="small">
<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 ellipsized">{this.state.fullName}
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>} {this.state.channel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}</div>
readOnly={!this.state.owner} </div>
value={this.state.fullName} {this.state.private ?
required={true} <div className="group">
onFinished={this.handleFullNameUpdate} /></div> <div><label className="small">
<div>
<label className="small">
<FormattedMessage id="label_private" defaultMessage="Private comment" <FormattedMessage id="label_private" defaultMessage="Private comment"
description="Label for editing 'private'" /> description="Label for editing 'private'" />
</label></div>
<div className="large ellipsized">{this.state.private}</div>
</div>
: null
}
<div className="group">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.state.address}</tt>
</div>
<div className="group">
<BadgeList trustedBadges={this.state.trustedBadges} />
</div>
{this.state.description ?
<div className="group">
<label className="small">
<FormattedMessage id="label_description" defaultMessage="Description"
description="Label for editing topic description" />
</label> </label>
</div> <div>{this.state.description}</div>
<div> </div> : null}
<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
avatar={this.state.avatar}
readOnly={!this.state.owner}
uid={this.props.topic}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div> </div>
<div className="hr" /> <div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row"> <div className="panel-form-row">
<label> <label>
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:" <FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
description="Label for Muting/unmuting the topic" /> description="Label for Muting/unmuting the topic" />
</label> </label>
<CheckBox name="P" checked={this.state.muted} <CheckBox name="P" checked={this.state.muted} onChange={this.handleMuted} />
onChange={this.handleMuted} />
</div> </div>
<FormattedMessage id="action_more" defaultMessage="More" {this.state.archived ?
description="Action for showing more content">{
(more) => <MoreButton
title={more}
open={this.state.moreInfoExpanded}
onToggle={this.handleMoreInfo} />
}</FormattedMessage>
{this.state.moreInfoExpanded ?
<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 ?
<div className="panel-form-row"> <div className="panel-form-row">
<label> <label>
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:" <FormattedMessage id="label_unarchive_topic" defaultMessage="Archived:"
description="Label for current user permissions" /> description="Label for unarchiving the topic" />
</label> </label>
<tt className="clickable" <CheckBox name="archived" checked={true} onChange={this.handleUnarchive} />
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt>
</div>
:
<div>
<div>
<label className="small">
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>
<FormattedMessage id="label_you" defaultMessage="You:"
description="Label for the current user" /> &nbsp;<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt></div>
<div>{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}:
&nbsp;<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
{this.state.modeGiven2}
</tt>
</div>
</div>
</div>
}
{this.state.sharer && (this.state.auth || this.state.anon) ?
<div>
<div>
<label className="small">
<FormattedMessage id="label_default_access" defaultMessage="Default access mode:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>Auth: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
{this.state.auth}
</tt>
:
<tt>{this.state.auth}</tt>
}
</div>
<div>Anon: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
{this.state.anon}
</tt>
:
<tt>{this.state.anon}</tt>
}
</div>
</div>
</div> </div>
: :
null null
} }
</div>
:
null
}
</div>
<div className="hr" /> <div className="hr" />
{this.state.owner ? <div className="panel-form-row">
<> <a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)" <i className="material-icons">security</i>&nbsp;<FormattedMessage id="button_security"
description="Section title for TagManager">{ defaultMessage="Security" description="Navigaton button for security panel." />
(tags) => <TagManager
title={tags}
tags={this.state.tags}
activated={false}
tinode={this.props.tinode}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
</>
:
null
}
<div className="panel-form-column">
{!this.state.channel ?
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
<i className="material-icons">delete_outline</i> &nbsp;{
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
}
</a> </a>
:
null
}
{this.state.owner ?
<a href="#" className="danger flat-button" onClick={this.handleDeleteTopic}>
<i className="material-icons">delete</i> &nbsp;{formatMessage(messages.topic_delete)}
</a>
:
<a href="#" className="danger flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> &nbsp;{formatMessage(messages.leave_chat)}
</a>
}
{!this.state.groupTopic ?
<a href="#" className="danger flat-button" onClick={this.handleBlock}>
<i className="material-icons">block</i> &nbsp;{formatMessage(messages.block_contact)}
</a>
:
null
}
{!this.state.owner ?
<a href="#" className="danger flat-button" onClick={this.handleReport}>
<i className="material-icons">report</i> &nbsp;{formatMessage(messages.report_chat)}
</a>
:
null
}
</div> </div>
{this.state.groupTopic && this.state.sharer ? {this.state.groupTopic && this.state.sharer ?
<> <>
<div className="hr" /> <div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row"> <div className="panel-form-row">
<label className="small"> <label className="small">
<FormattedMessage id="label_group_members" defaultMessage="Group members:" <FormattedMessage id="label_group_members" defaultMessage="Group members:"
@@ -784,6 +611,7 @@ class InfoView extends React.Component {
<FormattedMessage id="group_has_no_members" defaultMessage="No members" <FormattedMessage id="group_has_no_members" defaultMessage="No members"
description="Shown in place of group members">{ description="Shown in place of group members">{
(no_members) => <ContactList (no_members) => <ContactList
tinode={this.props.tinode}
contacts={this.state.contactList} contacts={this.state.contactList}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
emptyListMessage={no_members} emptyListMessage={no_members}
@@ -796,7 +624,6 @@ class InfoView extends React.Component {
showContextMenu={this.state.admin ? this.handleContextMenu : false} showContextMenu={this.state.admin ? this.handleContextMenu : false}
/> />
}</FormattedMessage> }</FormattedMessage>
</div>
</> </>
: :
null null
+273 -126
View File
@@ -5,6 +5,7 @@ import Tinode from 'tinode-sdk';
const Drafty = Tinode.Drafty; const Drafty = Tinode.Drafty;
import ChatMessage from '../widgets/chat-message.jsx'; import ChatMessage from '../widgets/chat-message.jsx';
import ContactBadges from '../widgets/contact-badges.jsx';
import DocPreview from '../widgets/doc-preview.jsx'; import DocPreview from '../widgets/doc-preview.jsx';
import ErrorPanel from '../widgets/error-panel.jsx'; import ErrorPanel from '../widgets/error-panel.jsx';
import GroupSubs from '../widgets/group-subs.jsx'; import GroupSubs from '../widgets/group-subs.jsx';
@@ -15,12 +16,14 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx'; import LogoView from './logo-view.jsx';
import SendMessage from '../widgets/send-message.jsx'; import SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE, import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, IMAGE_THUMBNAIL_DIM, KEYPRESS_DELAY,
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js'; MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64, READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
imageScaled, makeImageDataUrl } from '../lib/blob-helpers.js'; import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64,
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js'; import { bytesToHumanSize, shortDateFormat, letterTileColorId } from '../lib/strformat.js';
import { sanitizeImageUrl } from '../lib/utils.js';
// Run timer with this frequency (ms) for checking notification queue. // Run timer with this frequency (ms) for checking notification queue.
const NOTIFICATION_EXEC_INTERVAL = 300; const NOTIFICATION_EXEC_INTERVAL = 300;
@@ -50,7 +53,12 @@ const messages = defineMessages({
id: 'file_attachment_too_large', id: 'file_attachment_too_large',
defaultMessage: 'The file size {size} exceeds the {limit} limit.', defaultMessage: 'The file size {size} exceeds the {limit} limit.',
description: 'Error message when attachment is too large' description: 'Error message when attachment is too large'
} },
invalid_content: {
id: 'invalid_content',
defaultMessage: 'invalid content',
description: 'Shown when the message is unreadable'
},
}); });
// Checks if the access permissions are granted but not yet accepted. // Checks if the access permissions are granted but not yet accepted.
@@ -77,9 +85,12 @@ class MessagesView extends React.Component {
this.state = MessagesView.getDerivedStateFromProps(props, {}); this.state = MessagesView.getDerivedStateFromProps(props, {});
this.leave = this.leave.bind(this); this.leave = this.leave.bind(this);
this.sendMessage = this.sendMessage.bind(this);
this.retrySend = this.retrySend.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this); this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendFileAttachment = this.sendFileAttachment.bind(this); this.sendFileAttachment = this.sendFileAttachment.bind(this);
this.sendKeyPress = this.sendKeyPress.bind(this); this.sendKeyPress = this.sendKeyPress.bind(this);
this.subscribe = this.subscribe.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this); this.handleScrollReference = this.handleScrollReference.bind(this);
this.handleScrollEvent = this.handleScrollEvent.bind(this); this.handleScrollEvent = this.handleScrollEvent.bind(this);
this.handleDescChange = this.handleDescChange.bind(this); this.handleDescChange = this.handleDescChange.bind(this);
@@ -91,7 +102,7 @@ class MessagesView extends React.Component {
this.handleClosePreview = this.handleClosePreview.bind(this); this.handleClosePreview = this.handleClosePreview.bind(this);
this.handleFormResponse = this.handleFormResponse.bind(this); this.handleFormResponse = this.handleFormResponse.bind(this);
this.handleContextClick = this.handleContextClick.bind(this); this.handleContextClick = this.handleContextClick.bind(this);
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this); this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this); this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this); this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this); this.handleAttachFile = this.handleAttachFile.bind(this);
@@ -100,12 +111,27 @@ class MessagesView extends React.Component {
this.postReadNotification = this.postReadNotification.bind(this); this.postReadNotification = this.postReadNotification.bind(this);
this.clearNotificationQueue = this.clearNotificationQueue.bind(this); this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
this.handlePickReply = this.handlePickReply.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.chatMessageRefs = {};
this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this);
this.readNotificationQueue = []; this.readNotificationQueue = [];
this.readNotificationTimer = null; this.readNotificationTimer = null;
} }
getOrCreateMessageRef(seqId) {
if (this.chatMessageRefs.hasOwnProperty(seqId)) {
return this.chatMessageRefs[seqId];
}
const ref = React.createRef();
this.chatMessageRefs[seqId] = ref;
return ref;
}
componentDidMount() { componentDidMount() {
// this.propsChange(this.props, this.state);
if (this.messagesScroller) { if (this.messagesScroller) {
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent); this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
} }
@@ -157,49 +183,8 @@ class MessagesView extends React.Component {
this.postReadNotification(0); this.postReadNotification(0);
} }
if (topic && !topic.isSubscribed() && this.props.ready && if ((this.state.topic != prevState.topic) || !prevProps.ready) {
((this.state.topic != prevState.topic) || !prevProps.ready)) { this.subscribe(topic);
// Is this a new topic?
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
if (this.state.isReader || newTopic) {
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
// And show "loading" spinner.
this.setState({ fetchingMessages: true });
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic});
}
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
})
.catch((err) => {
console.log("Failed subscription to", this.state.topic);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
} }
} }
@@ -214,6 +199,9 @@ class MessagesView extends React.Component {
topic: null, topic: null,
title: '', title: '',
avatar: null, avatar: null,
isVerified: false,
isStaff: false,
isDangerous: false,
docPreview: null, docPreview: null,
imagePreview: null, imagePreview: null,
imagePostview: null, imagePostview: null,
@@ -221,10 +209,23 @@ class MessagesView extends React.Component {
scrollPosition: 0, scrollPosition: 0,
fetchingMessages: false, fetchingMessages: false,
peerMessagingDisabled: false, peerMessagingDisabled: false,
channel: false channel: false,
reply: null
}; };
} else if (nextProps.topic != prevState.topic) { } else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic); const topic = nextProps.tinode.getTopic(nextProps.topic);
let reply = null;
if (nextProps.forwardMessage) {
// We are forwarding a message. Show preview.
const preview = nextProps.forwardMessage.preview;
reply = {
content: preview,
forwarded: nextProps.forwardMessage.head.forwarded,
seq: null
};
}
nextState = { nextState = {
topic: nextProps.topic, topic: nextProps.topic,
docPreview: null, docPreview: null,
@@ -232,7 +233,8 @@ class MessagesView extends React.Component {
imagePostview: null, imagePostview: null,
typingIndicator: false, typingIndicator: false,
scrollPosition: 0, scrollPosition: 0,
fetchingMessages: false fetchingMessages: false,
reply: reply
}; };
if (topic) { if (topic) {
@@ -254,7 +256,7 @@ class MessagesView extends React.Component {
if (topic.public) { if (topic.public) {
Object.assign(nextState, { Object.assign(nextState, {
title: topic.public.fn, title: topic.public.fn,
avatar: makeImageDataUrl(topic.public.photo) avatar: makeImageUrl(topic.public.photo)
}); });
} else { } else {
Object.assign(nextState, { Object.assign(nextState, {
@@ -332,6 +334,55 @@ class MessagesView extends React.Component {
return nextState; return nextState;
} }
subscribe(topic) {
if (!topic || topic.isSubscribed() || !this.props.ready) {
return;
}
// Is this a new topic?
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
if (this.state.isReader || newTopic) {
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
// And show "loading" spinner.
this.setState({ fetchingMessages: true });
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic});
}
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
this.retrySend(pub);
}
});
})
.catch((err) => {
console.error("Failed subscription to", this.state.topic);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
}
leave(oldTopicName) { leave(oldTopicName) {
if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) { if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) {
return; return;
@@ -385,7 +436,7 @@ class MessagesView extends React.Component {
if (desc.public) { if (desc.public) {
this.setState({ this.setState({
title: desc.public.fn, title: desc.public.fn,
avatar: makeImageDataUrl(desc.public.photo) avatar: makeImageUrl(desc.public.photo)
}); });
} else { } else {
this.setState({ this.setState({
@@ -500,7 +551,8 @@ class MessagesView extends React.Component {
return; return;
} }
this.setState({messageCount: topic.messageCount()}); clearTimeout(this.keyPressTimer)
this.setState({messageCount: topic.messageCount(), typingIndicator: false});
// Scroll to the bottom if the message is added to the end of the message list. // Scroll to the bottom if the message is added to the end of the message list.
// TODO: This should be replaced by showing a "scroll to bottom" button. // TODO: This should be replaced by showing a "scroll to bottom" button.
@@ -530,9 +582,8 @@ class MessagesView extends React.Component {
switch (info.what) { switch (info.what) {
case 'kp': { case 'kp': {
clearTimeout(this.keyPressTimer); clearTimeout(this.keyPressTimer);
var instance = this; this.keyPressTimer = setTimeout(() => {
this.keyPressTimer = setTimeout(function() { this.setState({typingIndicator: false});
instance.setState({typingIndicator: false});
}, KEYPRESS_DELAY + 1000); }, KEYPRESS_DELAY + 1000);
if (!this.state.typingIndicator) { if (!this.state.typingIndicator) {
this.setState({typingIndicator: true}); this.setState({typingIndicator: true});
@@ -545,7 +596,7 @@ class MessagesView extends React.Component {
this.forceUpdate(); this.forceUpdate();
break; break;
default: default:
console.log("Other change in topic: ", info.what); console.info("Other change in topic: ", info.what);
} }
} }
@@ -562,7 +613,7 @@ class MessagesView extends React.Component {
handleFormResponse(action, text, data) { handleFormResponse(action, text, data) {
if (action == 'pub') { if (action == 'pub') {
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data)); this.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
} else if (action == 'url') { } else if (action == 'url') {
const url = new URL(data.ref); const url = new URL(data.ref);
const params = url.searchParams; const params = url.searchParams;
@@ -581,7 +632,7 @@ class MessagesView extends React.Component {
url.search = params; url.search = params;
window.open(url, '_blank'); window.open(url, '_blank');
} else { } else {
console.log("Unknown action in form", action); console.info("Unknown action in form", action);
} }
} }
@@ -591,7 +642,11 @@ class MessagesView extends React.Component {
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX }); this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
} }
handleShowContextMenuMessage(params, messageSpecificMenuItems) { handleShowMessageContextMenu(params, messageSpecificMenuItems) {
if (params.userFrom == 'chan') {
params.userFrom = this.state.topic;
params.userName = this.state.title;
}
params.topicName = this.state.topic; params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || []; const menuItems = messageSpecificMenuItems || [];
const topic = this.props.tinode.getTopic(params.topicName); const topic = this.props.tinode.getTopic(params.topicName);
@@ -623,6 +678,32 @@ class MessagesView extends React.Component {
} }
} }
// sendMessage sends the message with an optional subscription to topic first.
sendMessage(msg, uploadCompletionPromise, uploader) {
let head;
if (this.props.forwardMessage) {
// We are forwarding a message.
msg = this.props.forwardMessage.msg;
head = this.props.forwardMessage.head;
this.handleCancelReply();
} else if (this.state.reply && this.state.reply.content) {
// We are replying to a message in this topic.
head = {reply: '' + this.state.reply.seq};
// Turn it into Drafty so we can make a quoted Drafty object later.
if (typeof msg == 'string') {
msg = Drafty.parse(msg);
}
msg = Drafty.append(this.state.reply.content, msg);
this.handleCancelReply();
}
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
}
// Retry sending a message.
retrySend(pub) {
this.props.sendMessage(pub.content, undefined, undefined, pub.head);
}
// Send attachment as Drafty message: // Send attachment as Drafty message:
// - if file is too large, upload it and send a s link. // - if file is too large, upload it and send a s link.
// - if file is small enough, just send it in-band. // - if file is small enough, just send it in-band.
@@ -634,10 +715,6 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) { if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(file); const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, { const msg = Drafty.attachFile(null, {
mime: file.type, mime: file.type,
@@ -646,15 +723,12 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise urlPromise: uploadCompletionPromise
}); });
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader); this.sendMessage(msg, uploadCompletionPromise, uploader);
} else { } else {
// Small enough to send inband. // Small enough to send inband.
fileToBase64(file, fileToBase64(file)
(mime, bits, fname) => { .then(b64 => this.sendMessage(Drafty.attachFile(null, {mime: b64.mime, data: b64.bits, filename: b64.name})))
this.props.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname})); .catch(err => this.props.onError(err));
},
this.props.onError
);
} }
} }
@@ -667,21 +741,23 @@ class MessagesView extends React.Component {
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large, this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err'); {size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
} else { } else {
this.setState({ docPreview: { this.setState({
docPreview: {
file: file, file: file,
filename: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type type: file.type
}}); }
});
} }
} }
// sendImageAttachment sends the image bits inband as Drafty message. // sendImageAttachment sends the image bits inband as Drafty message.
sendImageAttachment(caption, blob) { sendImageAttachment(caption, blob) {
const mime = this.state.imagePreview.type; const mime = this.state.imagePreview.mime;
const width = this.state.imagePreview.width; const width = this.state.imagePreview.width;
const height = this.state.imagePreview.height; const height = this.state.imagePreview.height;
const fname = this.state.imagePreview.filename; const fname = this.state.imagePreview.name;
// Server-provided limit reduced for base64 encoding and overhead. // Server-provided limit reduced for base64 encoding and overhead.
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize', const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
@@ -697,13 +773,13 @@ class MessagesView extends React.Component {
const uploadCompletionPromise = uploader.upload(blob); const uploadCompletionPromise = uploader.upload(blob);
// Make small preview to show while uploading. // Make small preview to show while uploading.
imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false, imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false)
(tinyMine, tinyBlob) => {
// Convert tiny image into base64 for serialization and previewing. // Convert tiny image into base64 for serialization and previewing.
blobToBase64(tinyBlob, (blobMime, tinyBits64) => { .then(scaled => blobToBase64(scaled.blob))
.then(b64 => {
let msg = Drafty.insertImage(null, 0, { let msg = Drafty.insertImage(null, 0, {
mime: mime, mime: mime,
_tempPreview: tinyBits64, // This preview will not be serialized. _tempPreview: b64.bits, // This preview will not be serialized.
width: width, width: width,
height: height, height: height,
filename: fname, filename: fname,
@@ -712,25 +788,22 @@ class MessagesView extends React.Component {
}); });
if (caption) { if (caption) {
msg = Drafty.appendLineBreak(msg); msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.init(caption)); msg = Drafty.append(msg, Drafty.parse(caption));
} }
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader); this.sendMessage(msg, uploadCompletionPromise, uploader);
} }).catch((err) => {
)},
// Failure
(err) => {
this.props.onError(err, 'err'); this.props.onError(err, 'err');
} });
);
return; return;
} }
// Upload the image if it's too big to be send inband. // Upload the image if it's too big to be send inband.
blobToBase64(blob, (blobMime, bits64) => { blobToBase64(blob)
.then(b64 => {
let msg = Drafty.insertImage(null, 0, { let msg = Drafty.insertImage(null, 0, {
mime: blobMime, mime: b64.mime,
preview: bits64, // Serializable preview preview: b64.bits, // Serializable preview
width: width, width: width,
height: height, height: height,
filename: fname, filename: fname,
@@ -738,9 +811,9 @@ class MessagesView extends React.Component {
}); });
if (caption) { if (caption) {
msg = Drafty.appendLineBreak(msg); msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.init(caption)); msg = Drafty.append(msg, Drafty.parse(caption));
} }
this.props.sendMessage(msg); this.sendMessage(msg);
}); });
} }
@@ -749,24 +822,20 @@ class MessagesView extends React.Component {
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE); const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
// Get image dimensions and size, optionally scale it down. // Get image dimensions and size, optionally scale it down.
imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false, imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false)
// Success .then(scaled => {
(mime, blob, width, height, fname) => {
this.setState({imagePreview: { this.setState({imagePreview: {
url: URL.createObjectURL(blob), url: URL.createObjectURL(scaled.blob),
blob: blob, blob: scaled.blob,
filename: fname, name: scaled.name,
width: width, width: scaled.width,
height: height, height: scaled.height,
size: blob.size, size: scaled.blob.size,
type: mime mime: scaled.mime
}}); }});
}, }).catch(err => {
// Failure
(err) => {
this.props.onError(err, 'err'); this.props.onError(err, 'err');
} });
);
} }
handleCancelUpload(seq, uploader) { handleCancelUpload(seq, uploader) {
@@ -778,6 +847,49 @@ class MessagesView extends React.Component {
uploader.cancel(); uploader.cancel();
} }
handlePickReply(seq, content, forwarded, senderId, senderName) {
this.setState({reply: null});
if (!seq || !content) {
return;
}
content = forwarded ?
Drafty.forwardedContent(content) :
typeof content == 'string' ? Drafty.init(content) : content;
if (Drafty.isValid(content)) {
content = Drafty.preview(content, QUOTED_REPLY_LENGTH, undefined, !forwarded);
} else {
// /!\ invalid content.
content = Drafty.append(Drafty.init('\u26A0 '),
Drafty.wrapInto(this.props.intl.formatMessage(messages.invalid_content), 'EM'));
}
this.setState({
reply: {
content: Drafty.quote(senderName, senderId, content),
seq: seq
}
});
this.props.onCancelForwardMessage();
}
handleCancelReply() {
this.setState({reply: null});
this.props.onCancelForwardMessage();
}
handleQuoteClick(replyToSeq) {
const ref = this.getOrCreateMessageRef(replyToSeq);
if (ref && ref.current) {
ref.current.scrollIntoView({block: "center", behavior: "smooth"});
ref.current.classList.add('flash');
setTimeout(() => { ref.current.classList.remove('flash') } , 1000);
} else {
console.error("Unresolved message ref", replyToSeq);
}
}
render() { render() {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
@@ -797,6 +909,9 @@ class MessagesView extends React.Component {
component2 = ( component2 = (
<ImagePreview <ImagePreview
content={this.state.imagePreview} content={this.state.imagePreview}
tinode={this.props.tinode}
reply={this.state.reply}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview} onClose={this.handleClosePreview}
onSendMessage={this.sendImageAttachment} /> onSendMessage={this.sendImageAttachment} />
); );
@@ -812,6 +927,9 @@ class MessagesView extends React.Component {
component2 = ( component2 = (
<DocPreview <DocPreview
content={this.state.docPreview} content={this.state.docPreview}
tinode={this.props.tinode}
reply={this.state.reply}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview} onClose={this.handleClosePreview}
onSendMessage={this.sendFileAttachment} /> onSendMessage={this.sendFileAttachment} />
); );
@@ -819,11 +937,23 @@ class MessagesView extends React.Component {
const topic = this.props.tinode.getTopic(this.state.topic); const topic = this.props.tinode.getTopic(this.state.topic);
const isChannel = topic.isChannelType(); const isChannel = topic.isChannelType();
const groupTopic = topic.isGroupType() && !isChannel; const groupTopic = topic.isGroupType() && !isChannel;
const icon_badges = [];
if (topic.trusted) {
if (topic.trusted.verified) {
icon_badges.push({icon: 'verified', color: 'badge-inv'});
}
if (topic.trusted.staff) {
icon_badges.push({icon: 'staff', color: 'badge-inv'});
}
if (topic.trusted.danger) {
icon_badges.push({icon: 'dangerous', color: 'badge-inv'});
}
}
let messageNodes = []; let messageNodes = [];
let previousFrom = null; let previousFrom = null;
let chatBoxClass = null; let chatBoxClass = null;
topic.messages((msg, prev, next, i) => { topic.messages((msg, prev, next, i) => {
let nextFrom = next ? (next.from || null) : 'chan'; let nextFrom = next ? (next.from || 'chan') : null;
let sequence = 'single'; let sequence = 'single';
let thisFrom = msg.from || 'chan'; let thisFrom = msg.from || 'chan';
@@ -841,40 +971,50 @@ class MessagesView extends React.Component {
const isReply = !(thisFrom == this.props.myUserId); const isReply = !(thisFrom == this.props.myUserId);
const deliveryStatus = topic.msgStatus(msg, true); const deliveryStatus = topic.msgStatus(msg, true);
let userName, userAvatar, userFrom; let userFrom = thisFrom, userName, userAvatar;
if (groupTopic) {
const user = topic.userDesc(thisFrom); const user = topic.userDesc(thisFrom);
if (user && user.public) { if (user && user.public) {
userName = user.public.fn; userName = user.public.fn;
userAvatar = makeImageDataUrl(user.public.photo); userAvatar = makeImageUrl(user.public.photo);
} }
userFrom = thisFrom; chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
chatBoxClass='chat-box group';
} else { // Ref for this chat message.
chatBoxClass='chat-box'; const ref = this.getOrCreateMessageRef(msg.seq);
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
if (!replyToSeq || isNaN(replyToSeq)) {
replyToSeq = null;
} }
messageNodes.push( messageNodes.push(
<ChatMessage <ChatMessage
tinode={this.props.tinode} tinode={this.props.tinode}
content={msg.content} content={msg.content}
forwarded={msg.head ? msg.head.forwarded : null}
deleted={msg.hi} deleted={msg.hi}
mimeType={msg.head ? msg.head.mime : null} mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} timestamp={msg.ts}
response={isReply} response={isReply}
seq={msg.seq} seq={msg.seq}
isGroup={groupTopic}
isChan={this.state.channel}
userFrom={userFrom} userFrom={userFrom}
userName={userName} userName={userName}
userAvatar={userAvatar} userAvatar={userAvatar}
sequence={sequence} sequence={sequence}
received={deliveryStatus} received={deliveryStatus}
uploader={msg._uploader} uploader={msg._uploader}
viewportWidth={this.props.viewportWidth} viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.state.channel? false : this.handleShowContextMenuMessage} showContextMenu={this.handleShowMessageContextMenu}
onImagePreview={this.handleImagePostview} onImagePreview={this.handleImagePostview}
onFormResponse={this.handleFormResponse} onFormResponse={this.handleFormResponse}
onError={this.props.onError} onError={this.props.onError}
onCancelUpload={this.handleCancelUpload} onCancelUpload={this.handleCancelUpload}
pickReply={this.handlePickReply}
replyToSeq={replyToSeq}
onQuoteClick={this.handleQuoteClick}
ref={ref}
userIsWriter={this.state.isWriter}
key={msg.seq} /> key={msg.seq} />
); );
}); });
@@ -908,6 +1048,7 @@ class MessagesView extends React.Component {
null} null}
<div className="avatar-box"> <div className="avatar-box">
<LetterTile <LetterTile
tinode={this.props.tinode}
avatar={avatar} avatar={avatar}
topic={this.state.topic} topic={this.state.topic}
title={this.state.title} /> title={this.state.title} />
@@ -918,11 +1059,12 @@ class MessagesView extends React.Component {
this.state.title || this.state.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed" <i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i> description="Title shown when the topic has no name" /></i>
}</div> }<ContactBadges badges={icon_badges} /></div>
<div id="topic-last-seen">{lastSeen}</div> <div id="topic-last-seen">{lastSeen}</div>
</div> </div>
{groupTopic ? {groupTopic ?
<GroupSubs <GroupSubs
tinode={this.props.tinode}
subscribers={this.state.onlineSubs} /> : subscribers={this.state.onlineSubs} /> :
<div id="topic-users" /> <div id="topic-users" />
} }
@@ -968,12 +1110,17 @@ class MessagesView extends React.Component {
<Invitation onAction={this.handleNewChatAcceptance} /> <Invitation onAction={this.handleNewChatAcceptance} />
: :
<SendMessage <SendMessage
tinode={this.props.tinode}
noInput={!!this.props.forwardMessage}
disabled={!this.state.isWriter} disabled={!this.state.isWriter}
onSendMessage={this.props.sendMessage}
onKeyPress={this.sendKeyPress} onKeyPress={this.sendKeyPress}
onAttachFile={this.handleAttachFile} onSendMessage={this.sendMessage}
onAttachImage={this.handleAttachImage} onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
onError={this.props.onError} />} onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage}
onError={this.props.onError}
reply={this.state.reply}
onQuoteClick={this.handleQuoteClick}
onCancelReply={this.handleCancelReply} />}
</> </>
); );
} }
+15 -6
View File
@@ -22,7 +22,12 @@ const messages = defineMessages({
id: "search_no_results", id: "search_no_results",
defaultMessage: "Search returned no results", defaultMessage: "Search returned no results",
description: "Text shown in contacts view when query returned no results." description: "Text shown in contacts view when query returned no results."
} },
search_placeholder: {
id: "search_placeholder",
defaultMessage: "List like email:alice@example.com, tel:17025550003...",
description: "Placeholder in contacts search field"
},
}); });
class NewTopicView extends React.Component { class NewTopicView extends React.Component {
@@ -56,16 +61,17 @@ class NewTopicView extends React.Component {
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query}); this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
} }
handleContactSelected(sel) { handleContactSelected(uid) {
if (this.state.tabSelected === 'p2p') { if (this.state.tabSelected == 'p2p') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab')); HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(sel); this.props.onCreateTopic(uid);
} }
} }
handleNewGroupSubmit(name, dataUrl, priv, tags, isChannel) { handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab')); HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined, theCard(name, dataUrl), priv, tags, isChannel); this.props.onCreateTopic(undefined,
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
} }
handleGroupByID(topicName) { handleGroupByID(topicName) {
@@ -77,6 +83,7 @@ class NewTopicView extends React.Component {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
const no_contacts_placeholder = formatMessage(this.state.searchQuery ? const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
messages.search_no_results : messages.search_for_contacts); messages.search_no_results : messages.search_for_contacts);
const search_placeholder = formatMessage(messages.search_placeholder);
return ( return (
<div className="flex-column"> <div className="flex-column">
<ul className="tabbar"> <ul className="tabbar">
@@ -110,8 +117,10 @@ class NewTopicView extends React.Component {
<div className="flex-column"> <div className="flex-column">
<SearchContacts <SearchContacts
type="p2p" type="p2p"
placeholder={search_placeholder}
onSearchContacts={this.handleSearchContacts} /> onSearchContacts={this.handleSearchContacts} />
<ContactList <ContactList
tinode={this.props.tinode}
contacts={this.props.searchResults} contacts={this.props.searchResults}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
emptyListMessage={no_contacts_placeholder} emptyListMessage={no_contacts_placeholder}
+30 -17
View File
@@ -4,11 +4,11 @@ import { defineMessages, injectIntl } from 'react-intl';
import ErrorPanel from '../widgets/error-panel.jsx'; 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 TopicCommon from '../widgets/topic-common.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 AccGeneralView from './acc-general-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';
import AccSupportView from './acc-support-view.jsx'; import AccSupportView from './acc-support-view.jsx';
@@ -37,19 +37,24 @@ 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: {
id: 'sidepanel_title_acc_general', id: 'panel_title_general',
description: 'Sidepanel title for AccGeneralView.', description: 'Title for TopicCommon.',
defaultMessage: 'General' defaultMessage: 'General'
}, },
security: { security: {
id: 'sidepanel_title_acc_security', id: 'panel_title_security',
description: 'Sidepanel title for AccSecurityView.', description: 'Title for TopicSecirity and AccSecurity.',
defaultMessage: 'Security' defaultMessage: 'Security'
}, },
crop: {
id: 'panel_title_crop',
description: 'Title for AvatarCropView.',
defaultMessage: 'Drag to Adjust'
},
notif: { notif: {
id: 'sidepanel_title_acc_notifications', id: 'sidepanel_title_acc_notifications',
description: 'Sidepanel title for AccNotificationsView.', description: 'Sidepanel title for AccNotificationsView.',
@@ -95,20 +100,22 @@ class SidepanelView extends React.Component {
} }
handleNewTopic() { handleNewTopic() {
this.props.onBasicNavigate('newtpk'); this.props.onNavigate('newtpk');
} }
render() { render() {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login'); const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
let title, avatar; let title, avatar, badges;
if (view == 'contacts') { if (view == 'contacts') {
title = this.props.title; title = this.props.title;
avatar = this.props.avatar ? this.props.avatar : true; avatar = this.props.avatar ? this.props.avatar : true;
badges = this.props.trustedBadges;
} else { } else {
title = formatMessage(messages[view]); title = formatMessage(messages[view]);
avatar = false; avatar = false;
badges = null;
} }
let onCancel; let onCancel;
@@ -122,6 +129,8 @@ class SidepanelView extends React.Component {
state={view} state={view}
title={title} title={title}
avatar={avatar} avatar={avatar}
tinode={this.props.tinode}
trustedBadges={badges}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
onSignUp={this.props.onSignUp} onSignUp={this.props.onSignUp}
onSettings={this.props.onSettings} onSettings={this.props.onSettings}
@@ -147,6 +156,7 @@ class SidepanelView extends React.Component {
view === 'register' ? view === 'register' ?
<CreateAccountView <CreateAccountView
tinode={this.props.tinode}
onCreateAccount={this.props.onCreateAccount} onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel} onCancel={this.props.onCancel}
onError={this.props.onError} /> : onError={this.props.onError} /> :
@@ -159,21 +169,22 @@ 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}
onBasicNavigate={this.props.onBasicNavigate} /> : trustedBadges={this.props.trustedBadges}
onNavigate={this.props.onNavigate} /> :
view === 'general' ? (view === 'general' || view === 'crop') ?
<AccGeneralView <TopicCommon
topic="me"
tinode={this.props.tinode} tinode={this.props.tinode}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
onUpdateAccount={this.props.onUpdateAccount} onUpdateTopicDesc={this.props.onUpdateAccountDesc}
onUpdateTags={this.props.onUpdateAccountTags} onUpdateTags={this.props.onUpdateAccountTags}
onCredAdd={this.props.onCredAdd} onCredAdd={this.props.onCredAdd}
onCredDelete={this.props.onCredDelete} onCredDelete={this.props.onCredDelete}
onCredConfirm={this.props.onCredConfirm} onCredConfirm={this.props.onCredConfirm}
onBasicNavigate={this.props.onBasicNavigate}
onError={this.props.onError} /> : onError={this.props.onError} /> :
view === 'notif' ? view === 'notif' ?
@@ -189,7 +200,8 @@ class SidepanelView extends React.Component {
view === 'security' ? view === 'security' ?
<AccSecurityView <AccSecurityView
tinode={this.props.tinode} tinode={this.props.tinode}
onUpdateAccount={this.props.onUpdateAccount} onUpdateAccountDesc={this.props.onUpdateAccountDesc}
onUpdatePassword={this.props.onUpdatePassword}
onLogout={this.props.onLogout} onLogout={this.props.onLogout}
onDeleteAccount={this.props.onDeleteAccount} onDeleteAccount={this.props.onDeleteAccount}
onShowAlert={this.props.onShowAlert} onShowAlert={this.props.onShowAlert}
@@ -233,7 +245,8 @@ class SidepanelView extends React.Component {
view === 'reset' ? view === 'reset' ?
<PasswordResetView <PasswordResetView
onRequest={this.props.onPasswordResetRequest} onRequest={this.props.onPasswordResetRequest}
onReset={this.props.onResetPassword} /> : onReset={this.props.onResetPassword}
onCancel={this.props.onCancel} /> :
null} null}
</div> </div>
); );
+193 -72
View File
@@ -1,6 +1,5 @@
// The top-level class to hold all functionality together. // The top-level class to hold all functionality together.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import * as firebase from 'firebase/app'; import * as firebase from 'firebase/app';
@@ -10,15 +9,16 @@ import Tinode from 'tinode-sdk';
import Alert from '../widgets/alert.jsx'; import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx'; import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx';
import InfoView from './info-view.jsx'; import InfoView from './info-view.jsx';
import MessagesView from './messages-view.jsx'; import MessagesView from './messages-view.jsx';
import SidepanelView from './sidepanel-view.jsx'; import SidepanelView from './sidepanel-view.jsx';
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, LOGGING_ENABLED, import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, FORWARDED_PREVIEW_LENGTH, LOGGING_ENABLED,
MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js'; MEDIA_BREAKPOINT, QUOTED_REPLY_LENGTH, RECEIVED_DELAY } from '../config.js';
import { PACKAGE_VERSION } from '../version.js'; import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageDataUrl } from '../lib/blob-helpers.js'; import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js'; import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js'; import LocalStorageUtil from '../lib/local-storage.js';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
@@ -70,6 +70,8 @@ class TinodeWeb extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.selfRef = React.createRef();
this.state = this.getBlankState(); this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this); this.handleResize = this.handleResize.bind(this);
@@ -100,7 +102,7 @@ class TinodeWeb extends React.Component {
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this); this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
this.handleNewAccount = this.handleNewAccount.bind(this); this.handleNewAccount = this.handleNewAccount.bind(this);
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this); this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
this.handleUpdateAccountRequest = this.handleUpdateAccountRequest.bind(this); this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this);
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this); this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this); this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
this.handleSettings = this.handleSettings.bind(this); this.handleSettings = this.handleSettings.bind(this);
@@ -119,8 +121,9 @@ class TinodeWeb extends React.Component {
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this); this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this); this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this); this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
this.handleUnarchive = this.handleUnarchive.bind(this);
this.handleChangePermissions = this.handleChangePermissions.bind(this); this.handleChangePermissions = this.handleChangePermissions.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this); this.handleTagsUpdateRequest = this.handleTagsUpdateRequest.bind(this);
this.handleLogout = this.handleLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
this.handleDeleteAccount = this.handleDeleteAccount.bind(this); this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this); this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this);
@@ -133,12 +136,16 @@ class TinodeWeb extends React.Component {
this.handleHideContextMenu = this.handleHideContextMenu.bind(this); this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
this.handleShowAlert = this.handleShowAlert.bind(this); this.handleShowAlert = this.handleShowAlert.bind(this);
this.handleShowInfoView = this.handleShowInfoView.bind(this); this.handleShowInfoView = this.handleShowInfoView.bind(this);
this.handleHideInfoView = this.handleHideInfoView.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this); this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this); this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this); this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
this.handleResetPassword = this.handleResetPassword.bind(this); this.handleResetPassword = this.handleResetPassword.bind(this);
this.handleContextMenuAction = this.handleContextMenuAction.bind(this); this.handleContextMenuAction = this.handleContextMenuAction.bind(this);
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
this.sendMessageToTopic = this.sendMessageToTopic.bind(this);
} }
getBlankState() { getBlankState() {
@@ -157,7 +164,7 @@ class TinodeWeb extends React.Component {
// "On" is the default, so saving the "off" state. // "On" is the default, so saving the "off" state.
messageSounds: !settings.messageSoundsOff, messageSounds: !settings.messageSoundsOff,
incognitoMode: false, incognitoMode: false,
desktopAlerts: persist && settings.desktopAlerts, desktopAlerts: persist && !!settings.desktopAlerts,
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) && desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') && (typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
(typeof FIREBASE_INIT != 'undefined'), (typeof FIREBASE_INIT != 'undefined'),
@@ -173,6 +180,7 @@ class TinodeWeb extends React.Component {
sidePanelSelected: 'login', sidePanelSelected: 'login',
sidePanelTitle: null, sidePanelTitle: null,
sidePanelAvatar: null, sidePanelAvatar: null,
myTrustedBadges: [],
loadSpinnerVisible: false, loadSpinnerVisible: false,
login: '', login: '',
@@ -186,7 +194,7 @@ class TinodeWeb extends React.Component {
newTopicParams: null, newTopicParams: null,
loginDisabled: false, loginDisabled: false,
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT), displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
showInfoPanel: false, infoPanel: undefined,
mobilePanel: 'sidepanel', mobilePanel: 'sidepanel',
contextMenuVisible: false, contextMenuVisible: false,
@@ -195,6 +203,9 @@ class TinodeWeb extends React.Component {
contextMenuParams: null, contextMenuParams: null,
contextMenuItems: [], contextMenuItems: [],
forwardDialogVisible: false,
forwardMessage: null,
// Modal alert dialog. // Modal alert dialog.
alertVisible: false, alertVisible: false,
alertParams: {}, alertParams: {},
@@ -224,7 +235,7 @@ class TinodeWeb extends React.Component {
serviceWorkerChannel.addEventListener('message', this.handlePushMessage); serviceWorkerChannel.addEventListener('message', this.handlePushMessage);
} else { } else {
// Safari is broken by design. // Safari is broken by design.
console.warn('Your browser does not support BroadcastChannel. Some features will not be available'); console.warn("Your browser does not support BroadcastChannel. Some features will not be available");
} }
// Window/tab visible or invisible for pausing timers. // Window/tab visible or invisible for pausing timers.
@@ -386,7 +397,7 @@ class TinodeWeb extends React.Component {
const hash = HashNavigation.parseUrlHash(window.location.hash); const hash = HashNavigation.parseUrlHash(window.location.hash);
if (hash.path && hash.path.length > 0) { if (hash.path && hash.path.length > 0) {
// Left-side panel selector. // Left-side panel selector.
if (['register','settings','edit','notif','security','support','general', if (['register','settings','edit','notif','security','support','general','crop',
'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) { 'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) {
this.setState({sidePanelSelected: hash.path[0]}); this.setState({sidePanelSelected: hash.path[0]});
} else { } else {
@@ -424,7 +435,7 @@ class TinodeWeb extends React.Component {
// Additional parameters of panels. // Additional parameters of panels.
this.setState({ this.setState({
showInfoPanel: hash.params.info, infoPanel: hash.params.info,
newTopicTabSelected: hash.params.tab newTopicTabSelected: hash.params.tab
}); });
} }
@@ -550,6 +561,7 @@ class TinodeWeb extends React.Component {
errorLevel: err && err.message ? 'err' : 'warn', errorLevel: err && err.message ? 'err' : 'warn',
loginDisabled: false, loginDisabled: false,
contextMenuVisible: false, contextMenuVisible: false,
forwardDialogVisible: false,
serverVersion: "no connection" serverVersion: "no connection"
}); });
} }
@@ -564,8 +576,9 @@ class TinodeWeb extends React.Component {
cred = Tinode.credential(cred); cred = Tinode.credential(cred);
// Try to login with login/password. If they are not available, try token. If no token, ask for login/password. // Try to login with login/password. If they are not available, try token. If no token, ask for login/password.
let promise = null; let promise = null;
const token = this.tinode.getAuthToken(); let token = this.tinode.getAuthToken();
if (login && password) { if (login && password) {
token = null;
this.setState({password: null}); this.setState({password: null});
promise = this.tinode.loginBasic(login, password, cred); promise = this.tinode.loginBasic(login, password, cred);
} else if (token) { } else if (token) {
@@ -593,7 +606,9 @@ class TinodeWeb extends React.Component {
autoLogin: false autoLogin: false
}); });
this.handleError(err.message, 'err'); this.handleError(err.message, 'err');
localStorage.removeItem('auth-token'); if (token) {
this.handleLogout();
}
HashNavigation.navigateTo(''); HashNavigation.navigateTo('');
}); });
} else { } else {
@@ -661,7 +676,18 @@ class TinodeWeb extends React.Component {
if (desc.public) { if (desc.public) {
this.setState({ this.setState({
sidePanelTitle: desc.public.fn, sidePanelTitle: desc.public.fn,
sidePanelAvatar: makeImageDataUrl(desc.public.photo) sidePanelAvatar: makeImageUrl(desc.public.photo)
});
}
if (desc.trusted) {
const badges = [];
for (const [key, val] of Object.entries(desc.trusted)) {
if (val) {
badges.push(key);
}
}
this.setState({
myTrustedBadges: badges,
}); });
} }
if (desc.acs) { if (desc.acs) {
@@ -769,6 +795,7 @@ class TinodeWeb extends React.Component {
// Contacts expect c.topic to be set. // Contacts expect c.topic to be set.
c.topic = c.name; c.topic = c.name;
} }
newState.chatList.push(c); newState.chatList.push(c);
if (this.state.topicSelected == c.topic) { if (this.state.topicSelected == c.topic) {
newState.topicSelectedOnline = c.online; newState.topicSelectedOnline = c.online;
@@ -849,13 +876,14 @@ class TinodeWeb extends React.Component {
errorText: '', errorText: '',
errorLevel: null, errorLevel: null,
mobilePanel: 'topic-view', mobilePanel: 'topic-view',
showInfoPanel: false infoPanel: undefined
}); });
// Different contact selected. // Different contact selected.
if (this.state.topicSelected != topicName) { if (this.state.topicSelected != topicName) {
this.setState({ this.setState({
topicSelectedOnline: this.tinode.isTopicOnline(topicName), topicSelectedOnline: this.tinode.isTopicOnline(topicName),
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName) topicSelectedAcs: this.tinode.getTopicAccessMode(topicName),
forwardMessage: null
}); });
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName)); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
} }
@@ -867,7 +895,8 @@ class TinodeWeb extends React.Component {
mobilePanel: 'sidepanel', mobilePanel: 'sidepanel',
topicSelectedOnline: false, topicSelectedOnline: false,
topicSelectedAcs: null, topicSelectedAcs: null,
showInfoPanel: false infoPanel: undefined,
forwardMessage: null
}); });
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null)); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
@@ -887,18 +916,36 @@ class TinodeWeb extends React.Component {
// - msg - Drafty message with content // - msg - Drafty message with content
// - promise - Promise to be resolved when the upload is completed // - promise - Promise to be resolved when the upload is completed
// - uploader - for tracking progress // - uploader - for tracking progress
handleSendMessage(msg, promise, uploader) { // - head - head dictionary to be attached to the message
handleSendMessage(msg, promise, uploader, head) {
const topic = this.tinode.getTopic(this.state.topicSelected); const topic = this.tinode.getTopic(this.state.topicSelected);
this.sendMessageToTopic(topic, msg, promise, uploader, head);
}
sendMessageToTopic(topic, msg, promise, uploader, head) {
msg = topic.createMessage(msg, false); msg = topic.createMessage(msg, false);
// The uploader is used to show progress. // The uploader is used to show progress.
msg._uploader = uploader; msg._uploader = uploader;
if (head) {
msg.head = Object.assign(msg.head || {}, head);
}
if (!topic.isSubscribed()) { if (!topic.isSubscribed()) {
// Topic is not subscribed yet. Subscribe.
if (!promise) { if (!promise) {
promise = Promise.resolve(); promise = Promise.resolve();
} }
promise = promise.then(() => { return topic.subscribe(); }); promise = promise
.then(() => topic.subscribe())
.then(() => {
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
this.sendMessage(pub);
}
});
});
} }
if (promise) { if (promise) {
@@ -984,32 +1031,15 @@ class TinodeWeb extends React.Component {
}); });
} }
handleUpdateAccountRequest(password, pub, defacs) {
this.handleError();
if (pub || defacs) {
const params = {};
if (pub) {
params.public = pub;
}
if (defacs) {
params.defacs = defacs;
}
this.tinode.getMeTopic().setMeta({desc: params}).catch((err) => {
this.handleError(err.message, 'err');
});
}
if (password) {
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
this.handleError(err.message, 'err');
});
}
}
handleToggleIncognitoMode(on) { handleToggleIncognitoMode(on) {
// Make state undefined.
this.setState({incognitoMode: null});
const me = this.tinode.getMeTopic(); const me = this.tinode.getMeTopic();
const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant(); const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant();
me.setMeta({sub: {mode: am}}).catch((err) => { me.setMeta({sub: {mode: am}}).catch((err) => {
// Request failed, keep existing state.
this.setState({incognitoMode: !on});
this.handleError(err.message, 'err'); this.handleError(err.message, 'err');
}); });
} }
@@ -1070,6 +1100,7 @@ class TinodeWeb extends React.Component {
togglePushToken(enabled) { togglePushToken(enabled) {
if (enabled) { if (enabled) {
this.setState({desktopAlerts: null});
if (!this.state.firebaseToken) { if (!this.state.firebaseToken) {
const fcm = this.fbPush ? const fcm = this.fbPush ?
Promise.resolve() : Promise.resolve() :
@@ -1079,7 +1110,7 @@ class TinodeWeb extends React.Component {
}).then(() => { }).then(() => {
this.requestPushToken(); this.requestPushToken();
}).catch((err) => { }).catch((err) => {
console.warn("Failed to get notification permission.", err); console.error("Failed to get notification permission.", err);
this.handleError(err.message, 'err'); this.handleError(err.message, 'err');
this.setState({desktopAlerts: false, firebaseToken: null}); this.setState({desktopAlerts: false, firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
@@ -1092,7 +1123,7 @@ class TinodeWeb extends React.Component {
} }
} else if (this.state.firebaseToken && this.fbPush) { } else if (this.state.firebaseToken && this.fbPush) {
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => { this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
console.warn("Unable to delete token.", err); console.error("Unable to delete token.", err);
}).finally(() => { }).finally(() => {
LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
localStorage.removeItem('firebase-token'); localStorage.removeItem('firebase-token');
@@ -1121,7 +1152,7 @@ class TinodeWeb extends React.Component {
} }
}).catch((err) => { }).catch((err) => {
this.handleError(err.message, 'err'); this.handleError(err.message, 'err');
console.warn("Failed to retrieve firebase token", err); console.error("Failed to retrieve firebase token", err);
}); });
} }
@@ -1156,6 +1187,8 @@ class TinodeWeb extends React.Component {
let path = ''; let path = '';
if (['security','support','general','notif'].includes(parsed.path[0])) { if (['security','support','general','notif'].includes(parsed.path[0])) {
path = 'edit'; path = 'edit';
} else if ('crop' == parsed.path[0]) {
path = 'general';
} else if ('blocked' == parsed.path[0]) { } else if ('blocked' == parsed.path[0]) {
path = 'security'; path = 'security';
} else if (this.state.myUserId) { } else if (this.state.myUserId) {
@@ -1171,13 +1204,18 @@ class TinodeWeb extends React.Component {
this.setState({errorText: '', errorLevel: null}); this.setState({errorText: '', errorLevel: null});
} }
// Basic nagigator by hash value. No need to bind to this. // Sidepanel navigator. No need to bind to 'this'.
basicNavigator(hash) { basicNavigator(hash) {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash)); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
} }
// Topic info navigator. No need to bind to 'this'.
infoNavigator(hash) {
HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash));
}
// Request to start a topic, new or selected from search results, or "by ID". // Request to start a topic, new or selected from search results, or "by ID".
handleStartTopicRequest(topicName, pub, priv, tags, isChannel) { handleStartTopicRequest(topicName, newTopicParams, isChannel) {
// Check if topic is indeed new. If not, launch it. // Check if topic is indeed new. If not, launch it.
if (topicName && this.tinode.isTopicCached(topicName)) { if (topicName && this.tinode.isTopicCached(topicName)) {
this.handleTopicSelected(topicName); this.handleTopicSelected(topicName);
@@ -1192,14 +1230,14 @@ class TinodeWeb extends React.Component {
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}}; params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
} else { } else {
topicName = topicName || this.tinode.newGroupTopicName(isChannel); topicName = topicName || this.tinode.newGroupTopicName(isChannel);
params.desc = {public: pub, private: {comment: priv}}; params.desc = {public: newTopicParams.public, private: {comment: newTopicParams.private}};
params.tags = tags; params.tags = newTopicParams.tags;
} }
params._topicName = topicName; params._topicName = topicName;
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)}); this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
} }
// New topic was creted, here is the new topic name. // New topic was created, here is the new topic name.
handleNewTopicCreated(oldName, newName) { handleNewTopicCreated(oldName, newName) {
if (this.state.topicSelected == oldName && oldName != newName) { if (this.state.topicSelected == oldName && oldName != newName) {
// If the current URl contains the old topic name, replace it with new. // If the current URl contains the old topic name, replace it with new.
@@ -1211,21 +1249,49 @@ class TinodeWeb extends React.Component {
} }
} }
handleTopicUpdateRequest(topicName, pub, priv, permissions) { handleTopicUpdateRequest(topicName, pub, priv, defacs) {
this.handleError();
const topic = this.tinode.getTopic(topicName); const topic = this.tinode.getTopic(topicName);
if (topic) { if (topic) {
const params = {}; const params = {};
let attachments;
if (pub) { if (pub) {
if (pub.photo) {
if (pub.photo.ref && pub.photo.ref != Tinode.DEL_CHAR) {
attachments = [pub.photo.ref];
} else if (!pub.photo.data || pub.photo.data == Tinode.DEL_CHAR) {
pub.photo = Tinode.DEL_CHAR;
}
}
params.public = pub; params.public = pub;
} }
if (priv) {
if (typeof priv == 'string') {
params.private = (priv === Tinode.DEL_CHAR) ? params.private = (priv === Tinode.DEL_CHAR) ?
Tinode.DEL_CHAR : {comment: priv}; Tinode.DEL_CHAR : {comment: priv};
} }
if (permissions) { if (defacs) {
params.defacs = permissions; params.defacs = defacs;
} }
topic.setMeta({desc: params}).catch((err) => { topic.setMeta({desc: params, attachments: attachments}).catch((err) => {
this.handleError(err.message, 'err');
});
}
}
handleUnarchive(topicName) {
const topic = this.tinode.getTopic(topicName);
if (topic) {
topic.archive(false);
}
}
handleUpdatePasswordRequest(password) {
this.handleError();
if (password) {
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
this.handleError(err.message, 'err'); this.handleError(err.message, 'err');
}); });
} }
@@ -1248,7 +1314,7 @@ class TinodeWeb extends React.Component {
} }
} }
handleTagsUpdated(topicName, tags) { handleTagsUpdateRequest(topicName, tags) {
const topic = this.tinode.getTopic(topicName); const topic = this.tinode.getTopic(topicName);
if (topic) { if (topic) {
topic.setMeta({tags: tags}).catch((err) => { topic.setMeta({tags: tags}).catch((err) => {
@@ -1373,7 +1439,33 @@ class TinodeWeb extends React.Component {
contextMenuClickAt: {x: params.x, y: params.y}, contextMenuClickAt: {x: params.x, y: params.y},
contextMenuParams: params, contextMenuParams: params,
contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName), contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect() contextMenuBounds: this.selfRef.current.getBoundingClientRect()
});
}
handleShowForwardDialog(params) {
if (this.state.sidePanelSelected == 'newtpk') {
// If the Find panel is active, close it.
this.handleSidepanelCancel();
}
const header = '➦ ' + params.userName;
const content = params.forwarded ?
Tinode.Drafty.forwardedContent(params.content) :
typeof params.content == 'string' ?
Tinode.Drafty.init(params.content) : params.content;
const preview = Tinode.Drafty.preview(content, FORWARDED_PREVIEW_LENGTH,
undefined, params.forwarded != null);
const msg = Tinode.Drafty.append(
Tinode.Drafty.appendLineBreak(
Tinode.Drafty.mention(header, params.userFrom)),
content);
const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview);
const head = {
forwarded: params.topicName + ':' + params.seq
};
this.setState({
forwardDialogVisible: true,
forwardMessage: { head: head, msg: msg, preview: msgPreview }
}); });
} }
@@ -1417,6 +1509,13 @@ class TinodeWeb extends React.Component {
}); });
} }
handleHideForwardDialog(keepForwardedMessage) {
this.setState({
forwardDialogVisible: false,
forwardMessage: keepForwardedMessage ? this.state.forwardMessage : null
});
}
handleContextMenuAction(action, promise, params) { handleContextMenuAction(action, promise, params) {
if (action == 'topic_archive') { if (action == 'topic_archive') {
if (promise && params.topicName && params.topicName == this.state.topicSelected) { if (promise && params.topicName && params.topicName == this.state.topicSelected) {
@@ -1424,6 +1523,8 @@ class TinodeWeb extends React.Component {
this.handleTopicSelected(null); this.handleTopicSelected(null);
}); });
} }
} else if (action == 'menu_item_forward') {
this.handleShowForwardDialog(params);
} }
} }
@@ -1442,13 +1543,8 @@ class TinodeWeb extends React.Component {
} }
handleShowInfoView() { handleShowInfoView() {
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', true)); HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
this.setState({showInfoPanel: true}); this.setState({infoPanel: 'info'});
}
handleHideInfoView() {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'info'));
this.setState({showInfoPanel: false});
} }
handleMemberUpdateRequest(topicName, added, removed) { handleMemberUpdateRequest(topicName, added, removed) {
@@ -1524,7 +1620,7 @@ class TinodeWeb extends React.Component {
render() { render() {
return ( return (
<div id="app-container"> <div id="app-container" ref={this.selfRef}>
{this.state.contextMenuVisible ? {this.state.contextMenuVisible ?
<ContextMenu <ContextMenu
tinode={this.tinode} tinode={this.tinode}
@@ -1544,6 +1640,22 @@ class TinodeWeb extends React.Component {
: :
null null
} }
{this.state.forwardDialogVisible ?
<ForwardDialog
tinode={this.tinode}
contacts={this.state.chatList}
topicSelected={this.state.topicSelected}
myUserId={this.state.myUserId}
hide={this.handleHideForwardDialog}
onInitFind={this.tnInitFind}
searchResults={this.state.searchResults}
onSearchContacts={this.handleSearchContacts}
onTopicSelected={this.handleStartTopicRequest}
/>
:
null
}
<Alert <Alert
visible={this.state.alertVisible} visible={this.state.alertVisible}
title={this.state.alertParams.title} title={this.state.alertParams.title}
@@ -1561,6 +1673,7 @@ class TinodeWeb extends React.Component {
state={this.state.sidePanelSelected} state={this.state.sidePanelSelected}
title={this.state.sidePanelTitle} title={this.state.sidePanelTitle}
avatar={this.state.sidePanelAvatar} avatar={this.state.sidePanelAvatar}
trustedBadges={this.state.myTrustedBadges}
login={this.state.login} login={this.state.login}
persist={this.state.persist} persist={this.state.persist}
myUserId={this.state.myUserId} myUserId={this.state.myUserId}
@@ -1588,11 +1701,12 @@ class TinodeWeb extends React.Component {
onGlobalSettings={this.handleGlobalSettings} onGlobalSettings={this.handleGlobalSettings}
onSignUp={this.handleNewAccount} onSignUp={this.handleNewAccount}
onSettings={this.handleSettings} onSettings={this.handleSettings}
onBasicNavigate={this.basicNavigator} onNavigate={this.basicNavigator}
onLoginRequest={this.handleLoginRequest} onLoginRequest={this.handleLoginRequest}
onPersistenceChange={this.handlePersistenceChange} onPersistenceChange={this.handlePersistenceChange}
onCreateAccount={this.handleNewAccountRequest} onCreateAccount={this.handleNewAccountRequest}
onUpdateAccount={this.handleUpdateAccountRequest} onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest} onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
onTogglePushNotifications={this.togglePushToken} onTogglePushNotifications={this.togglePushToken}
onToggleMessageSounds={this.handleToggleMessageSounds} onToggleMessageSounds={this.handleToggleMessageSounds}
@@ -1629,13 +1743,18 @@ class TinodeWeb extends React.Component {
viewportWidth={this.state.viewportWidth} viewportWidth={this.state.viewportWidth}
viewportHeight={this.state.viewportHeight} viewportHeight={this.state.viewportHeight}
hideSelf={this.state.displayMobile && hideSelf={this.state.displayMobile &&
(this.state.mobilePanel !== 'topic-view' || this.state.showInfoPanel)} (this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)}
topic={this.state.topicSelected} topic={this.state.topicSelected}
myUserId={this.state.myUserId} myUserId={this.state.myUserId}
// User public.fn.
myUserName={this.state.sidePanelTitle}
serverVersion={this.state.serverVersion} serverVersion={this.state.serverVersion}
serverAddress={this.state.serverAddress} serverAddress={this.state.serverAddress}
applicationVisible={this.state.applicationVisible} applicationVisible={this.state.applicationVisible}
forwardMessage={this.state.forwardMessage}
onCancelForwardMessage={this.handleHideForwardDialog}
errorText={this.state.errorText} errorText={this.state.errorText}
errorLevel={this.state.errorLevel} errorLevel={this.state.errorLevel}
errorAction={this.state.errorAction} errorAction={this.state.errorAction}
@@ -1652,7 +1771,7 @@ class TinodeWeb extends React.Component {
onNewChat={this.handleNewChatInvitation} onNewChat={this.handleNewChatInvitation}
sendMessage={this.handleSendMessage} /> sendMessage={this.handleSendMessage} />
{this.state.showInfoPanel ? {this.state.infoPanel ?
<InfoView <InfoView
tinode={this.tinode} tinode={this.tinode}
connected={this.state.connected} connected={this.state.connected}
@@ -1660,14 +1779,15 @@ class TinodeWeb extends React.Component {
topic={this.state.topicSelected} topic={this.state.topicSelected}
searchableContacts={this.state.searchableContacts} searchableContacts={this.state.searchableContacts}
myUserId={this.state.myUserId} myUserId={this.state.myUserId}
panel={this.state.infoPanel}
errorText={this.state.errorText} errorText={this.state.errorText}
errorLevel={this.state.errorLevel} errorLevel={this.state.errorLevel}
errorAction={this.state.errorAction} errorAction={this.state.errorAction}
errorActionText={this.state.errorActionText} errorActionText={this.state.errorActionText}
onTopicDescUpdate={this.handleTopicUpdateRequest} onNavigate={this.infoNavigator}
onCancel={this.handleHideInfoView} onTopicDescUpdateRequest={this.handleTopicUpdateRequest}
onShowAlert={this.handleShowAlert} onShowAlert={this.handleShowAlert}
onChangePermissions={this.handleChangePermissions} onChangePermissions={this.handleChangePermissions}
onMemberUpdateRequest={this.handleMemberUpdateRequest} onMemberUpdateRequest={this.handleMemberUpdateRequest}
@@ -1677,7 +1797,8 @@ class TinodeWeb extends React.Component {
onBlockTopic={this.handleBlockTopicRequest} onBlockTopic={this.handleBlockTopicRequest}
onReportTopic={this.handleReportTopic} onReportTopic={this.handleReportTopic}
onAddMember={this.handleManageGroupMembers} onAddMember={this.handleManageGroupMembers}
onTopicTagsUpdate={this.handleTagsUpdated} onTopicTagsUpdateRequest={this.handleTagsUpdateRequest}
onTopicUnArchive={this.handleUnarchive}
onInitFind={this.tnInitFind} onInitFind={this.tnInitFind}
onError={this.handleError} onError={this.handleError}
+3 -3
View File
@@ -78,12 +78,12 @@ class ValidationView extends React.PureComponent {
}</FormattedMessage> }</FormattedMessage>
</div> </div>
<div className="dialog-buttons"> <div className="dialog-buttons">
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
</button>
<button className="secondary" onClick={this.handleCancel}> <button className="secondary" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" /> <FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button> </button>
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
</button>
</div> </div>
</div> </div>
); );
+68
View File
@@ -0,0 +1,68 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Cropper from './cropper.jsx';
import { imageCrop } from '../lib/blob-helpers.js';
export default class AvatarCrop extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
top: 0,
left: 0,
width: 0,
height: 0,
scale: 1
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
}
// Cut out in image coordinates and desired scale factor.
handleChange(left, top, width, height, scale) {
this.setState({
left: left,
top: top,
width: width,
height: height,
scale: scale
});
}
handleSubmit() {
imageCrop(this.props.mime, this.props.avatar,
this.state.left, this.state.top, this.state.width, this.state.height,
this.state.scale)
.then(img => {
this.props.onSubmit(img.mime, img.blob, img.width, img.height);
})
.catch(err => {
this.props.onError(err);
});
}
render() {
return (
<div className="panel-form">
<div className="panel-form-row">
<Cropper
source={this.props.avatar}
onChange={this.handleChange} />
</div>
<div className="dialog-buttons">
{this.props.onCancel ?
<button className="secondary" onClick={this.props.onCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
: null}
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
</div>
</div>
);
}
};
+17 -25
View File
@@ -1,42 +1,32 @@
import React from 'react'; import React from 'react';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import LoadSpinner from './load-spinner.jsx';
import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js'; import { sanitizeImageUrl } from '../lib/utils.js';
import { imageScaled, blobToBase64, makeImageDataUrl } from '../lib/blob-helpers.js';
import { AVATAR_SIZE } from '../config.js';
export default class AvatarUpload extends React.Component { export default class AvatarUpload extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
dataUrl: props.avatar source: props.avatar
}; };
this.handleFileUpload = this.handleFileUpload.bind(this); this.handleFileReceived = this.handleFileReceived.bind(this);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.avatar != prevProps.avatar) { if (this.props.avatar != prevProps.avatar) {
this.setState({dataUrl: this.props.avatar}) this.setState({source: this.props.avatar});
} }
} }
handleFileUpload(e) { handleFileReceived(e) {
imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true, const image = e.target.files[0];
// Image successfully scaled and converted. this.props.onImageUpdated(image.type, URL.createObjectURL(image), image.name);
(mime, blob) => {
// Convert blob to base64-encoded bits.
blobToBase64(blob, (unused, base64bits) => {
const du = makeImageDataUrl({data: base64bits, type: mime});
this.setState({dataUrl: du});
this.props.onImageChanged(du);
});
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
// Clear the value so the same file can be uploaded again. // Clear the value so the same file can be uploaded again.
e.target.value = ''; e.target.value = '';
} }
@@ -48,16 +38,17 @@ export default class AvatarUpload extends React.Component {
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : ''); const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
return ( return (
<div className={className}> <div className={className}>
{this.props.readOnly || !this.state.dataUrl ? {this.props.readOnly || !this.state.source ?
null : null :
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageChanged(null);}}> <a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageUpdated();}}>
<i className="material-icons">clear</i> <i className="material-icons">clear</i>
</a>} </a>}
{this.state.dataUrl ? {this.state.source ?
<img src={this.state.dataUrl} className="preview" /> : <img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
this.props.readOnly && this.props.uid ? this.props.readOnly && this.props.uid ?
<div className="avatar-box"> <div className="avatar-box">
<LetterTile <LetterTile
tinode={this.props.tinode}
avatar={true} avatar={true}
topic={this.props.uid} topic={this.props.uid}
title={this.props.title} /> title={this.props.title} />
@@ -66,11 +57,12 @@ export default class AvatarUpload extends React.Component {
<div className="blank">{AVATAR_SIZE}&times;{AVATAR_SIZE}</div>} <div className="blank">{AVATAR_SIZE}&times;{AVATAR_SIZE}</div>}
{this.props.readOnly ? null : {this.props.readOnly ? null :
<input type="file" id={randId} className="inputfile hidden" <input type="file" id={randId} className="inputfile hidden"
accept="image/*" onChange={this.handleFileUpload} />} accept="image/*" onChange={this.handleFileReceived} />}
{this.props.readOnly ? null : {this.props.readOnly ? null :
<label htmlFor={randId} className="round"> <label htmlFor={randId} className="round">
<i className="material-icons">file_upload</i> <i className="material-icons">file_upload</i>
</label>} </label>}
<LoadSpinner show={this.props.uploading} large={true} clear={true} centered={true} />
</div> </div>
); );
} }
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
const icon_mapping = {'staff': 'verified_user'};
const messages = defineMessages({
badge_verified: {
id: 'badge_verified',
defaultMessage: 'Verified/official',
description: 'Explanation of a verified account or topic badge'
},
badge_staff: {
id: 'badge_staff',
defaultMessage: 'Staff-managed',
description: 'Explanation of a staff-managed account or topic badge'
},
badge_danger: {
id: 'badge_danger',
defaultMessage: 'Untrustworthy',
description: 'Suspicious or untrustworthy account or topic badge'
},
});
/* Trusted badges with labels: verified, staff, danger */
class BadgeList extends React.PureComponent {
render() {
const {formatMessage} = this.props.intl;
let badges = null;
if (this.props.trustedBadges && this.props.trustedBadges.length > 0) {
badges = [];
this.props.trustedBadges.map((b) => {
const comment = formatMessage(messages['badge_' + b]);
const style = 'material-icons ' + b + '-color';
badges.push(<div className="trusted-badge" key={b}><i className={style}>{icon_mapping[b] || b}</i> {comment}</div>);
});
return <>{badges}</>;
}
return null;
}
};
export default injectIntl(BadgeList);
+61 -101
View File
@@ -1,15 +1,16 @@
// Single message, sent or received. // Single message, sent or received.
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk';
import Attachment from './attachment.jsx'; import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx' import ReceivedMarker from './received-marker.jsx'
import UploadingImage from './uploading-image.jsx'
import { fullFormatter, quoteFormatter } from '../lib/formatters.js';
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js'; import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
export default class ChatMessage extends React.Component { class BaseChatMessage extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -25,6 +26,17 @@ export default class ChatMessage extends React.Component {
this.handleFormButtonClick = this.handleFormButtonClick.bind(this); this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this); this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this); this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.formatterContext = {
getFormatter: (tp) => { return tp == 'QQ' ? quoteFormatter : null; },
formatMessage: props.intl.formatMessage.bind(props.intl),
viewportWidth: props.viewportWidth,
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
onImagePreview: this.handleImagePreview,
onFormButtonClick: this.handleFormButtonClick,
onQuoteClick: this.handleQuoteClick
};
} }
handleImagePreview(e) { handleImagePreview(e) {
@@ -59,9 +71,27 @@ export default class ChatMessage extends React.Component {
handleContextClick(e) { handleContextClick(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : []; const menuItems = [];
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content, if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
y: e.pageY, x: e.pageX }, menuItems); menuItems.push('menu_item_send_retry');
}
if (this.props.userIsWriter &&
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
menuItems.push('menu_item_reply');
}
menuItems.push('menu_item_forward');
this.props.showContextMenu({
seq: this.props.seq,
content: this.props.content,
userFrom: this.props.userFrom,
userName: this.props.userName,
forwarded: this.props.forwarded,
y: e.pageY,
x: e.pageX,
pickReply: this.props.pickReply
}, menuItems);
} }
handleProgress(ratio) { handleProgress(ratio) {
@@ -72,18 +102,27 @@ export default class ChatMessage extends React.Component {
this.props.onCancelUpload(this.props.seq, this.props.uploader); this.props.onCancelUpload(this.props.seq, this.props.uploader);
} }
handleQuoteClick(e) {
e.preventDefault();
e.stopPropagation();
const replyToSeq = this.props.replyToSeq;
if (replyToSeq) {
this.props.onQuoteClick(replyToSeq);
}
}
render() { render() {
const sideClass = this.props.deleted ? 'center' : const sideClass = this.props.deleted ? 'center' :
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right')); (this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble'; const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.deleted ? null : (this.props.userAvatar || true); const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
const fullDisplay = (this.props.userFrom && this.props.response && !this.props.deleted && const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
(this.props.sequence == 'single' || this.props.sequence == 'last')); (this.props.sequence == 'single' || this.props.sequence == 'last'));
let content = this.props.content; let content = this.props.content;
const attachments = []; const attachments = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) { if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, function(att, i) { Drafty.attachments(content, (att, i) => {
if (att.mime == 'application/json') { if (att.mime == 'application/json') {
// Don't show json objects as attachments. // Don't show json objects as attachments.
// They are not meant for users. // They are not meant for users.
@@ -101,7 +140,8 @@ export default class ChatMessage extends React.Component {
onError={this.props.onError} onError={this.props.onError}
key={i} />); key={i} />);
}, this); }, this);
content = React.createElement(React.Fragment, null, Drafty.format(content, draftyFormatter, this)); const tree = Drafty.format(content, fullFormatter, this.formatterContext);
content = React.createElement(React.Fragment, null, tree);
} else if (this.props.deleted) { } else if (this.props.deleted) {
// Message represents a range of deleted messages. // Message represents a range of deleted messages.
content = <><i className="material-icons gray">block</i> <i className="gray"> content = <><i className="material-icons gray">block</i> <i className="gray">
@@ -109,19 +149,18 @@ export default class ChatMessage extends React.Component {
defaultMessage="content deleted" description="Shown when messages are deleted" /> defaultMessage="content deleted" description="Shown when messages are deleted" />
</i></> </i></>
} else if (typeof content != 'string') { } else if (typeof content != 'string') {
content = <> content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
<i className="material-icons gray">error_outline</i> <i className="gray">
<FormattedMessage id="invalid_content" <FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when message is unreadable" /></i> defaultMessage="invalid content" description="Shown when the message is unreadable" /></i></>
</>
} }
return ( return (
<li className={sideClass}> <li ref={this.props.innerRef} className={sideClass}>
{this.props.userFrom && this.props.response ? {this.props.isGroup && this.props.response ?
<div className="avatar-box"> <div className="avatar-box">
{fullDisplay ? {fullDisplay ?
<LetterTile <LetterTile
tinode={this.props.tinode}
topic={this.props.userFrom} topic={this.props.userFrom}
title={this.props.userName} title={this.props.userName}
avatar={avatar} /> : avatar={avatar} /> :
@@ -152,10 +191,10 @@ export default class ChatMessage extends React.Component {
</div> </div>
{fullDisplay ? {fullDisplay ?
<div className="author"> <div className="author">
<FormattedMessage id="user_not_found" defaultMessage="Not found" {this.props.userName ||
description="In place of a user's full name when the user is not found.">{ <i><FormattedMessage id="user_not_found" defaultMessage="Not found"
(notFound) => {return this.props.userName || <i>{notFound}</i>} description="In place of a user's full name when the user is not found." /></i>
}</FormattedMessage> }
</div> </div>
: null : null
} }
@@ -165,86 +204,7 @@ export default class ChatMessage extends React.Component {
} }
}; };
// Convert Drafty object to a tree of React elements. const IntlChatMessage = injectIntl(BaseChatMessage);
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js'; const ChatMessage = React.forwardRef((props, ref) => <IntlChatMessage innerRef = {ref} {...props} />);
import { fitImageSize } from '../lib/blob-helpers.js';
// Converts Drafty elements into React classes. export default ChatMessage;
// 'this' is set by the caller.
function draftyFormatter(style, data, values, key) {
if (style == 'EX') {
// attachments are handled elsewhere.
return null;
}
let el = Drafty.tagName(style);
if (el) {
const attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch (style) {
case 'HL':
// Highlighted text. Assign class name.
attr.className = 'highlight';
break;
case 'IM':
// Additional processing for images
if (data) {
attr.className = 'inline-image';
const dim = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5), REM_SIZE * 24, false) ||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
attr.style = {
width: dim.dstWidth + 'px',
height: dim.dstHeight + 'px',
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
minWidth: dim.dstWidth + 'px',
minHeight: dim.dstHeight + 'px'
};
if (!Drafty.isProcessing(data)) {
attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src));
attr.alt = data.name;
if (attr.src) {
attr.onClick = this.handleImagePreview;
attr.className += ' image-clickable';
attr.loading = 'lazy';
} else {
attr.src = 'img/broken_image.png';
}
} else {
// Use custom element instead of <img>.
el = UploadingImage;
}
}
break;
case 'BN':
// Button
attr.onClick = this.handleFormButtonClick;
let inner = React.Children.map(values, (child) => {
return typeof child == 'string' ? child : undefined;
});
if (!inner || inner.length == 0) {
inner = [attr.name]
}
// Get text which will be sent back when the button is clicked.
attr['data-title'] = inner.join('');
break;
case 'FM':
// Form
attr.className = 'bot-form';
break;
case 'RW':
// Form element formatting is dependent on element content.
break;
default:
if (el == '_UNKN') {
// Unknown element.
// TODO: make it prettier.
el = <><span className="material-icons">extension</span></>;
}
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
+4 -2
View File
@@ -15,9 +15,11 @@ export default class CheckBox extends React.PureComponent {
render() { render() {
return ( return (
this.props.onChange ? ( this.props.onChange ? (
this.props.checked ? this.props.checked === true ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> : <i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i> this.props.checked === false ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i> :
<i className="material-icons lt-blue">indeterminate_check_box</i>
) : ( ) : (
this.props.checked ? this.props.checked ?
<i className="material-icons">check_box</i> : <i className="material-icons">check_box</i> :
+6 -3
View File
@@ -2,7 +2,7 @@ import React from 'react';
import Chip from './chip.jsx'; import Chip from './chip.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
/* BEGIN ChipInput: group membership widget */ /* BEGIN ChipInput: group membership widget */
export default class ChipInput extends React.Component { export default class ChipInput extends React.Component {
@@ -118,8 +118,9 @@ export default class ChipInput extends React.Component {
this.state.sortedChips.map((item) => { this.state.sortedChips.map((item) => {
chips.push( chips.push(
<Chip <Chip
tinode={this.props.tinode}
onCancel={this.handleChipCancel} onCancel={this.handleChipCancel}
avatar={makeImageDataUrl(item.public ? item.public.photo : null)} avatar={makeImageUrl(item.public ? item.public.photo : null)}
title={item.public ? item.public.fn : undefined} title={item.public ? item.public.fn : undefined}
noAvatar={this.props.avatarDisabled} noAvatar={this.props.avatarDisabled}
topic={item.user} topic={item.user}
@@ -131,6 +132,7 @@ export default class ChipInput extends React.Component {
count++; count++;
}); });
const className = "chip-input" + (this.state.focused ? ' focused' : ''); const className = "chip-input" + (this.state.focused ? ' focused' : '');
const autoFocus = !(this.props.tabIndex > 0);
return ( return (
<div className={className}> <div className={className}>
{chips} {chips}
@@ -141,7 +143,8 @@ export default class ChipInput extends React.Component {
onBlur={this.handleFocusLost} onBlur={this.handleFocusLost}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
value={this.state.input} value={this.state.input}
autoFocus /> tabIndex={this.props.tabIndex}
autoFocus={autoFocus} />
</div> </div>
); );
} }
+1
View File
@@ -23,6 +23,7 @@ export default class Chip extends React.PureComponent {
<span className="spacer" /> : <span className="spacer" /> :
<div className="avatar-box"> <div className="avatar-box">
<LetterTile <LetterTile
tinode={this.props.tinode}
avatar={this.props.avatar || true} avatar={this.props.avatar || true}
topic={this.props.topic} topic={this.props.topic}
title={this.props.title} /> title={this.props.title} />
+5 -5
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block'}; const icon_mapping = {'muted': 'notifications_off', 'banned': 'block', 'staff': 'verified_user'};
/* Contact's labels: [you], or icons [muted], [blocked] */ /* Contact's labels: [you], or icons [muted], [blocked] */
export default class ContactBadges extends React.PureComponent { export default class ContactBadges extends React.PureComponent {
@@ -8,12 +8,12 @@ export default class ContactBadges extends React.PureComponent {
let badges = null; let badges = null;
if (this.props.badges && this.props.badges.length > 0) { if (this.props.badges && this.props.badges.length > 0) {
badges = []; badges = [];
this.props.badges.map(function(b) { this.props.badges.map((b) => {
const color = (b.color ? ' ' + b.color : '');
if (b.icon) { if (b.icon) {
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>); badges.push(<i className={'material-icons as-badge' + color} key={b.key || b.icon}>{icon_mapping[b.icon] || b.icon}</i>);
} else { } else {
const style = 'badge' + (b.color ? ' ' + b.color : ''); badges.push(<span className={'badge' + color} key={b.key || b.name}>{b.name}</span>);
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
} }
}); });
return <>{badges}</>; return <>{badges}</>;
+10 -3
View File
@@ -9,7 +9,7 @@ import Tinode from 'tinode-sdk';
import Contact from './contact.jsx'; import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx'; import ContactAction from './contact-action.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
import { MESSAGE_PREVIEW_LENGTH } from '../config.js'; import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
@@ -77,22 +77,26 @@ class ContactList extends React.Component {
const comment = Array.isArray(c.private) ? const comment = Array.isArray(c.private) ?
c.private.join(',') : (c.private ? c.private.comment : null); c.private.join(',') : (c.private ? c.private.comment : null);
let preview; let preview;
let forwarded;
let deliveryStatus; let deliveryStatus;
if (!this.props.showMode && c.latestMessage) { if (!this.props.showMode && c.latestMessage) {
const msg = c.latestMessage(true); const msg = c.latestMessage(true);
if (msg) { if (msg) {
forwarded = msg.head ? msg.head.forwarded : null;
deliveryStatus = msg._status || c.msgStatus(msg, true); deliveryStatus = msg._status || c.msgStatus(msg, true);
preview = typeof msg.content == 'string' ? preview = typeof msg.content == 'string' ?
msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) : msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) :
Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH); Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH, undefined, forwarded != null);
} }
} }
contactNodes.push( contactNodes.push(
<Contact <Contact
tinode={this.props.tinode}
title={c.public ? c.public.fn : null} title={c.public ? c.public.fn : null}
avatar={makeImageDataUrl(c.public ? c.public.photo : null)} avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment} comment={comment}
preview={preview} preview={preview}
forwarded={forwarded}
received={deliveryStatus} received={deliveryStatus}
unread={this.props.showUnread ? c.unread : 0} unread={this.props.showUnread ? c.unread : 0}
now={c.online && this.props.connected} now={c.online && this.props.connected}
@@ -105,6 +109,9 @@ class ContactList extends React.Component {
isChannel={isChannel} isChannel={isChannel}
onSelected={this.props.onTopicSelected} onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu} showContextMenu={this.props.showContextMenu}
isVerified={c.trusted && c.trusted.verified}
isStaff={c.trusted && c.trusted.staff}
isDangerous={c.trusted && c.trusted.danger}
item={key} item={key}
index={contactNodes.length} index={contactNodes.length}
key={key} /> key={key} />
+22 -81
View File
@@ -1,6 +1,6 @@
// A single topic or user. // A single topic or user.
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { FormattedMessage, injectIntl } from 'react-intl';
import ContactBadges from './contact-badges.jsx'; import ContactBadges from './contact-badges.jsx';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
@@ -9,26 +9,9 @@ import UnreadBadge from './unread-badge.jsx';
import { Drafty } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk';
import { previewFormatter } from '../lib/formatters.js';
import { deliveryMarker } from '../lib/utils.js'; import { deliveryMarker } from '../lib/utils.js';
const messages = defineMessages({
drafty_image: {
id: 'drafty_image',
defaultMessage: 'Picture',
description: 'Comment for embedded images in drafty preview'
},
drafty_form: {
id: 'drafty_form',
defaultMessage: 'Form: ',
description: 'Comment for form in drafty preview'
},
drafty_attachment: {
id: 'drafty_attachment',
defaultMessage: 'Attachment',
description: 'Comment for attachment in drafty preview'
},
});
class Contact extends React.Component { class Contact extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -65,6 +48,15 @@ class Contact extends React.Component {
const avatar = this.props.avatar ? this.props.avatar : true; const avatar = this.props.avatar ? this.props.avatar : true;
const badges = this.props.badges ? this.props.badges.slice() : []; const badges = this.props.badges ? this.props.badges.slice() : [];
const icon_badges = []; const icon_badges = [];
if (this.props.isVerified) {
icon_badges.push({icon: 'verified', color: 'verified-color'});
}
if (this.props.isStaff) {
icon_badges.push({icon: 'staff', color: 'staff-color'});
}
if (this.props.isDangerous) {
icon_badges.push({icon: 'dangerous', color: 'danger-color'});
}
if (this.props.acs) { if (this.props.acs) {
if (this.props.showMode) { if (this.props.showMode) {
badges.push({name: this.props.acs.getMode(), key: 'mode'}); badges.push({name: this.props.acs.getMode(), key: 'mode'});
@@ -79,7 +71,14 @@ class Contact extends React.Component {
const subtitle = this.props.preview ? const subtitle = this.props.preview ?
(typeof this.props.preview == 'string' ? this.props.preview : (typeof this.props.preview == 'string' ? this.props.preview :
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, draftyFormatter, this))) : Drafty.isValid(this.props.preview) ?
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, previewFormatter,
{formatMessage: this.props.intl.formatMessage})) :
<><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i>
</>
) :
this.props.comment; this.props.comment;
const icon = deliveryMarker(this.props.received); const icon = deliveryMarker(this.props.received);
@@ -90,6 +89,7 @@ class Contact extends React.Component {
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}> <li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
<div className="avatar-box"> <div className="avatar-box">
<LetterTile <LetterTile
tinode={this.props.tinode}
avatar={avatar} avatar={avatar}
title={this.props.title} title={this.props.title}
topic={this.props.item} /> topic={this.props.item} />
@@ -101,11 +101,11 @@ class Contact extends React.Component {
<div className="text-box"> <div className="text-box">
<div><span className="contact-title">{title}</span> <div><span className="contact-title">{title}</span>
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null} {this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} /> <ContactBadges badges={icon_badges} /><UnreadBadge count={this.props.unread} />
</div> </div>
{this.props.showMode ? {this.props.showMode ?
<span><ContactBadges badges={badges} /></span> : <span><ContactBadges badges={badges} /></span> :
<div className="contact-comment">{marker}{subtitle || '\u00A0'}</div>} this.props.small ? null : <div className="contact-comment">{marker}<span>{subtitle || '\u00A0'}</span></div>}
</div> </div>
{this.props.showContextMenu ? {this.props.showContextMenu ?
<span className="menuTrigger"> <span className="menuTrigger">
@@ -118,63 +118,4 @@ class Contact extends React.Component {
} }
}; };
// Converts Drafty object into a one-line preview.
function draftyFormatter(style, data, values, key) {
let el = Drafty.tagName(style);
const attr = { key: key };
if (el) {
const { formatMessage } = this.props.intl;
switch (style) {
case 'BR':
// Replace new line with a space.
el = React.Fragment;
values = [' '];
break;
case 'HL':
// Make highlight less prominent in preview.
attr.className = 'highlight preview';
break;
case 'LN':
// Disable links in previews.
el = 'span';
break;
case 'IM':
// Replace image with '[icon] Image'.
el = React.Fragment;
values = [<i key="im" className="material-icons">photo</i>, formatMessage(messages.drafty_image)];
break;
case 'BN':
el = 'span';
attr.className = 'flat-button faux';
break;
case 'FM':
el = React.Fragment;
values = [<i key="fm" className="material-icons">dashboard</i>,
formatMessage(messages.drafty_form)].concat(values || []);
break;
case 'RW':
el = React.Fragment;
break;
case 'EX':
if (data && data.mime == 'application/json') {
// Ignore JSON attachments: they are form response payloads.
return null;
}
el = React.Fragment;
values = [<i key="ex" className="material-icons">attachment</i>, formatMessage(messages.drafty_attachment)];
break;
default:
if (el == '_UNKN') {
el = React.Fragment;
values = [<i key="unkn" className="material-icons">extension</i>];
}
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
export default injectIntl(Contact); export default injectIntl(Contact);
+32 -6
View File
@@ -1,10 +1,8 @@
// Context Menu: popup/dropdown menu. // Context Menu: popup/dropdown menu.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import { REM_SIZE } from '../config.js'; import { REM_SIZE } from '../config.js';
import HashNavigation from '../lib/navigation.js';
const messages = defineMessages({ const messages = defineMessages({
info: { info: {
@@ -47,6 +45,16 @@ const messages = defineMessages({
defaultMessage: 'Unmute', defaultMessage: 'Unmute',
description: 'Turn notifications on' description: 'Turn notifications on'
}, },
reply: {
id: 'menu_item_reply',
defaultMessage: 'Reply',
description: 'Reply to message'
},
forward: {
id: 'menu_item_forward',
defaultMessage: 'Forward',
description: 'Forward message'
},
topic_delete: { topic_delete: {
id: 'menu_item_delete_topic', id: 'menu_item_delete_topic',
defaultMessage: 'Delete', defaultMessage: 'Delete',
@@ -109,6 +117,8 @@ class ContextMenu extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.selfRef = React.createRef();
const {formatMessage} = props.intl; const {formatMessage} = props.intl;
this.handlePageClick = this.handlePageClick.bind(this); this.handlePageClick = this.handlePageClick.bind(this);
@@ -171,6 +181,18 @@ class ContextMenu extends React.Component {
return this.retryMessage(params, errorHandler); return this.retryMessage(params, errorHandler);
} }
}, },
'menu_item_reply': {
id: 'menu_item_reply',
title: formatMessage(messages.reply),
handler: (params, errorHandler) => {
return this.replyToMessage(params, errorHandler);
}
},
'menu_item_forward': {
id: 'menu_item_forward',
title: formatMessage(messages.forward),
handler: () => {} /* the action is taken directly in tinode-web */
},
'topic_unmute': { 'topic_unmute': {
id: 'topic_unmute', id: 'topic_unmute',
title: formatMessage(messages.unmute), title: formatMessage(messages.unmute),
@@ -318,7 +340,7 @@ class ContextMenu extends React.Component {
} }
handlePageClick(e) { handlePageClick(e) {
if (ReactDOM.findDOMNode(this).contains(e.target)) { if (this.selfRef.current.contains(e.target)) {
return; return;
} }
e.preventDefault(); e.preventDefault();
@@ -342,7 +364,7 @@ class ContextMenu extends React.Component {
} }
if (!item) { if (!item) {
console.log("Invalid menu item ID", e.currentTarget.dataset.id); console.error("Invalid menu item ID", e.currentTarget.dataset.id);
} else { } else {
this.props.onAction( this.props.onAction(
item.id, item.id,
@@ -411,9 +433,13 @@ class ContextMenu extends React.Component {
return result; return result;
} }
replyToMessage(params, errorHandler) {
params.pickReply(params.seq, params.content, params.forwarded, params.userFrom, params.userName, errorHandler);
}
render() { render() {
const menu = [];
let count = 0; let count = 0;
let menu = [];
this.props.items.map((item) => { this.props.items.map((item) => {
if (typeof item == 'string') { if (typeof item == 'string') {
item = this.MenuItems[item]; item = this.MenuItems[item];
@@ -445,7 +471,7 @@ class ContextMenu extends React.Component {
}; };
return ( return (
<ul className="menu" style={position}> <ul className="menu" style={position} ref={this.selfRef}>
{menu} {menu}
</ul> </ul>
); );
+279
View File
@@ -0,0 +1,279 @@
// Image zoom and crop widget.
import React from 'react';
const DEFAULT_MAX_ZOOM = 2.5;
export default class Cropper extends React.Component {
constructor(props) {
super(props);
this.state = {
// Coordinates of the top left corner of the image in container coordinates.
panX: 0,
panY: 0,
// Origin of the zoom in image coordinates.
originX: 0,
originY: 0,
// Zoom scale.
zoom: 1,
// Zoom min and max.
minZoom: 0,
maxZoom: DEFAULT_MAX_ZOOM,
};
// References to DOM elements for hooking up events and getting screen dimensions.
// The transparent element which captures mouse moves (moves with the image).
this.overlay = React.createRef();
// The image cutout window or view port (static).
this.cutout = React.createRef();
// Scaled and translated image preview.
this.preview = React.createRef();
// The main container (static).
this.boundingBox = React.createRef();
// The original image width and height before any scaling.
this.imageWidth = 0;
this.imageHeight = 0;
// Mouse position when dragging.
this.mouseX = 0
this.mouseY = 0;
// Length of the previous mouse move when dragging.
this.prevDistance = 0;
// Bounding rectangles of static elements.
this.cutoutRect = {};
this.bBoxRect = {};
// Center of the bounding box.
this.originX = 0;
this.originY = 0;
this.initScaling = this.initScaling.bind(this);
this.onZoom = this.onZoom.bind(this);
this.handleZoom = this.handleZoom.bind(this);
this.mouseDown = this.mouseDown.bind(this);
this.mouseUp = this.mouseUp.bind(this);
this.mouseMove = this.mouseMove.bind(this);
this.mouseTouch = this.mouseTouch.bind(this);
this.positionAll = this.positionAll.bind(this);
this.translate = this.translate.bind(this);
}
componentDidMount() {
this.overlay.current.addEventListener('mousedown', this.mouseDown, { passive: true });
this.overlay.current.addEventListener('touchstart', this.mouseDown, { passive: true });
// The rectangle position is in viewport coordinates.
this.bBoxRect = this.boundingBox.current.getBoundingClientRect();
// Center of the bounding box in container coordinates.
this.originX = this.bBoxRect.width / 2;
this.originY = this.bBoxRect.height / 2;
// The rectangle position is in viewport coordinates.
this.cutoutRect = this.cutout.current.getBoundingClientRect();
}
componentWillUnmount() {
this.overlay.current.removeEventListener('mousedown', this.mouseDown);
this.overlay.current.removeEventListener('touchstart', this.mouseDown);
}
// Position all elements.
positionAll(panX, panY, zoom) {
// Zoom origin in image preview coordinates.
this.setState({
panX: panX,
panY: panY,
zoom: zoom,
originX: this.originX - panX,
originY: this.originY - panY,
});
// Pass cut out coordinates to caller.
const left = (this.originX - panX) * zoom - this.originX;
const top = (this.originY - panY) * zoom - this.originY;
this.props.onChange(
(left + this.cutoutRect.left - this.bBoxRect.left) / zoom,
(top + this.cutoutRect.top - this.bBoxRect.top) / zoom,
this.cutoutRect.width / zoom,
this.cutoutRect.height / zoom,
zoom);
}
// Check if new location is within the limits.
static checkBound(currPan, img, cutout, delta) {
let nextDiff = Math.min(0, cutout[0] - img[0] - delta, img[1] - cutout[1] + delta);
if (nextDiff == 0) {
// Cutout is completely within the image.
currPan += delta;
} else if (Math.min(0, cutout[0] - img[0], img[1] - cutout[1]) < nextDiff) {
// Cutout is outside of the range but closer to the bound.
currPan += delta;
}
return currPan;
}
initScaling() {
// Initialize components.
const imgRect = this.preview.current.getBoundingClientRect();
this.imageWidth = imgRect.width;
this.imageHeight = imgRect.height;
// Set zoom slider's min and max values. Make sure the scaled image cannot be smaller than the cutout.
const minZoom = Math.max(this.cutoutRect.width / imgRect.width, this.cutoutRect.height / imgRect.height);
this.setState({
minZoom: minZoom,
maxZoom: Math.max(DEFAULT_MAX_ZOOM, minZoom + 1)
});
// Initial zoom level fills the bounding box at the smallest image dimension and overflows the largest, i.e. "fill" not "fit".
const zoom = Math.max(this.bBoxRect.width / imgRect.width, this.bBoxRect.height / imgRect.height);
// Converting from viewport coordinates to container, then panning.
const panX = this.cutoutRect.left - this.bBoxRect.left -
(imgRect.width - this.cutoutRect.width) / 2;
const panY = this.cutoutRect.top - this.bBoxRect.top -
(imgRect.height - this.cutoutRect.height) / 2;
this.positionAll(panX, panY, zoom);
}
onZoom(e) {
this.handleZoom(e.target.value);
}
handleZoom(zoom) {
let panX = this.state.panX;
let panY = this.state.panY;
// Ensure that the image at the new zoom is not outside of the cutout boundaries.
// Calculate image coordinates at the new zoom.
const imgLeft = this.originX - (this.originX - panX) * zoom;
const imgRight = imgLeft + this.imageWidth * zoom;
// Cutout in the same coordinates as the image.
const coLeft = this.cutoutRect.left - this.bBoxRect.left;
const coRight = coLeft + this.cutoutRect.width;
if (coLeft < imgLeft) {
panX -= imgLeft - coLeft;
} else if (coRight > imgRight) {
panX += coRight - imgRight;
}
const imgTop = this.originY - (this.originY - panY) * zoom;
const imgBottom = imgTop + this.imageHeight * zoom;
const coTop = this.cutoutRect.top - this.bBoxRect.top;
const coBottom = coTop + this.cutoutRect.height;
if (coTop < imgTop) {
panY -= imgTop - coTop;
} else if (coBottom > imgBottom) {
panY += coBottom - imgBottom;
}
this.positionAll(panX, panY, zoom);
}
mouseDown(e) {
if (e.touches) {
this.mouseX = e.touches[0].pageX;
this.mouseY = e.touches[0].pageY;
} else {
this.mouseX = e.pageX;
this.mouseY = e.pageY;
}
window.addEventListener('mousemove', this.mouseMove, { passive: false });
window.addEventListener('touchmove', this.mouseTouch, { passive: false });
window.addEventListener('mouseup', this.mouseUp, { passive: true });
window.addEventListener('touchend', this.mouseUp, { passive: true });
// Disable text selection in the entire document.
document.body.style['userSelect'] = 'none';
}
// Perform image panning.
translate(pageX, pageY) {
const dX = pageX - this.mouseX;
const dY = pageY - this.mouseY;
this.mouseX = pageX;
this.mouseY = pageY;
// Make sure the image is within the cutout window.
const imgRect = this.preview.current.getBoundingClientRect();
// Check if the new position is within the boundaries, and if not if it's closer to them.
let panX = Cropper.checkBound(this.state.panX, [imgRect.left, imgRect.right],
[this.cutoutRect.left, this.cutoutRect.right], dX);
let panY = Cropper.checkBound(this.state.panY, [imgRect.top, imgRect.bottom],
[this.cutoutRect.top, this.cutoutRect.bottom], dY);
this.positionAll(panX, panY, this.state.zoom);
}
// Image panning.
mouseMove(e) {
e.preventDefault();
this.translate(e.pageX, e.pageY);
}
mouseTouch(e) {
e.preventDefault();
if (e.touches.length == 1) {
this.translate(e.touches[0].pageX, e.touches[0].pageY);
return;
}
// Image zooming by pinching.
const [touch0, touch1] = e.touches;
const distance = Math.sqrt((touch0.pageX - touch1.pageX) * (touch0.pageX - touch1.pageX) +
(touch0.pageY - touch1.pageY) * (touch0.pageY - touch1.pageY));
if (!this.prevDistance) {
this.prevDistance = distance / this.state.zoom;
}
let scale = (distance / this.prevDistance);
this.handleZoom(Math.max(this.minZoom, Math.min(this.maxZoom, scale)));
}
mouseUp(e) {
window.removeEventListener('mousemove', this.mouseMove);
window.removeEventListener('touchmove', this.mouseTouch);
window.removeEventListener('mouseup', this.mouseUp);
window.removeEventListener('touchend', this.mouseUp);
// Re-enable text selection.
document.body.style['userSelect'] = '';
this.positionAll(this.state.panX, this.state.panY, this.state.zoom);
}
render() {
// transform3d: position and scale.
const t3d = `translate3d(${this.state.panX}px, ${this.state.panY}px, 0) scale(${this.state.zoom})`;
// transformOrigin: zoom origin.
const orig = `${this.state.originX}px ${this.state.originY}px`;
// Overlay position and size are exactly equal to the position and size of the transformed image
// except it uses different coordinates.
const overlay = {
top: `${this.originY - this.state.originY * this.state.zoom}px`,
left: `${this.originX - this.state.originX * this.state.zoom}px`,
width: `${this.imageWidth * this.state.zoom}px`,
height: `${this.imageHeight * this.state.zoom}px`
};
return (
<div className="cropper">
<div className="bounding-box" ref={this.boundingBox}>
<img src={this.props.source} className="preview" alt=""
style={{transform: t3d, transformOrigin: orig}} ref={this.preview} onLoad={this.initScaling} />
<div className="cutout circle" ref={this.cutout} />
<div className="overlay" style={overlay} ref={this.overlay} />
</div>
<div className="zoom-wrapper">
<input type="range" className="zoomer"
step="0.001" min={this.state.minZoom} max={this.state.maxZoom} value={this.state.zoom} onChange={this.onZoom} />
</div>
</div>
);
}
}
+7 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import SendMessage from '../widgets/send-message.jsx'; import SendMessage from '../widgets/send-message.jsx';
import { bytesToHumanSize } from '../lib/strformat.js'; import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
// Get material icon name from mime type. // Get material icon name from mime type.
function iconFromMime(mime) { function iconFromMime(mime) {
@@ -31,12 +31,14 @@ export default class DocPreview extends React.PureComponent {
return ( return (
<div id="image-preview"> <div id="image-preview">
<div id="image-preview-caption-panel"> <div id="image-preview-caption-panel">
<span>{this.props.content.filename}</span> <span>{this.props.content.name}</span>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a> <a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div> </div>
<div id="image-preview-container"> <div id="image-preview-container">
<div className="flex-column narrow"> <div className="flex-column narrow">
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i> <i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b> {shortenFileName(this.props.content.name, 24) || '-'}</div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:" <div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div> description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div>
<div><b><FormattedMessage id="label_size" defaultMessage="Size:" <div><b><FormattedMessage id="label_size" defaultMessage="Size:"
@@ -45,6 +47,9 @@ export default class DocPreview extends React.PureComponent {
</div> </div>
<SendMessage <SendMessage
noInput={true} noInput={true}
tinode={this.props.tinode}
reply={this.props.reply}
onCancelReply={this.props.onCancelReply}
onSendMessage={this.handleSendDoc} onSendMessage={this.handleSendDoc}
onError={this.props.onError} /> onError={this.props.onError} />
</div> </div>
+88
View File
@@ -0,0 +1,88 @@
// Forward Menu: message forwarding popup/dropdown menu.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import ContactList from './contact-list.jsx';
import SearchContacts from './search-contacts.jsx';
export default class ForwardDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
query: null
};
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
}
componentDidMount() {
this.props.onInitFind();
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
// Not forwarding the message.
this.props.hide(false);
}
}
handleClose(e) {
e.preventDefault();
this.props.hide(false);
}
handleSearchContacts(query) {
this.setState({ query: Tinode.isNullValue(query) ? null : query });
this.props.onSearchContacts(query);
}
handleContactSelected(uid) {
this.props.onTopicSelected(uid);
this.props.hide(true);
}
render() {
let contacts = this.state.query != null ? this.props.searchResults : this.props.contacts;
// Filter out contacts without a 'W' or 'J' permissions as well as the current topic.
contacts = contacts.filter((c) => {
return (c.name != this.props.topicSelected) && c.acs.isJoiner() && c.acs.isWriter();
});
return (
<div className="alert-container">
<div className="forward-dialog">
<div className="title with-control">
<div><FormattedMessage id="forward_to" defaultMessage="Forward to"
desription="Title of the contact selector dialog when forwarding a message" /></div>
<div><a href="#" onClick={this.handleClose}><i className="material-icons">close</i></a></div>
</div>
<FormattedMessage id="forward_to_search_placeholder" defaultMessage="Search contacts"
description="Contact search prompt when forwarding a message.">{
(search_placeholder) => <SearchContacts
placeholder={search_placeholder}
onSearchContacts={this.handleSearchContacts} />
}</FormattedMessage>
<FormattedMessage id="search_no_results" defaultMessage="Search returned no results"
description="Text shown in contacts view when query returned no results.">{
(not_found_placeholder) => <ContactList
tinode={this.props.tinode}
contacts={contacts}
myUserId={this.props.myUserId}
emptyListMessage={not_found_placeholder}
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
}</FormattedMessage>
</div>
</div>
);
}
}
+5 -3
View File
@@ -165,6 +165,7 @@ class GroupManager extends React.Component {
</div> </div>
<div className="panel-form-row"> <div className="panel-form-row">
<ChipInput <ChipInput
tinode={this.props.tinode}
chips={this.state.members} chips={this.state.members}
staticMembers={this.state.staticMembers} staticMembers={this.state.staticMembers}
prompt="add members" prompt="add members"
@@ -178,6 +179,7 @@ class GroupManager extends React.Component {
</label> </label>
</div> </div>
<ContactList <ContactList
tinode={this.props.tinode}
contacts={this.props.contacts} contacts={this.props.contacts}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
topicSelected={this.state.selectedContacts} topicSelected={this.state.selectedContacts}
@@ -188,12 +190,12 @@ class GroupManager extends React.Component {
showUnread={false} showUnread={false}
onTopicSelected={this.handleContactSelected} /> onTopicSelected={this.handleContactSelected} />
<div id="group-manager-buttons" className="panel-form-row"> <div id="group-manager-buttons" className="panel-form-row">
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
<button className="secondary" onClick={this.handleCancel}> <button className="secondary" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" /> <FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button> </button>
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
</div> </div>
</div> </div>
); );
+3 -2
View File
@@ -6,7 +6,7 @@ import { FormattedMessage } from 'react-intl';
import { MAX_ONLINE_IN_TOPIC } from '../config.js' import { MAX_ONLINE_IN_TOPIC } from '../config.js'
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
export default class GroupSubs extends React.Component { export default class GroupSubs extends React.Component {
constructor(props) { constructor(props) {
@@ -22,8 +22,9 @@ export default class GroupSubs extends React.Component {
usersOnline.push( usersOnline.push(
<div className="avatar-box" key={sub.user}> <div className="avatar-box" key={sub.user}>
<LetterTile <LetterTile
tinode={this.props.tinode}
topic={sub.user} topic={sub.user}
avatar={makeImageDataUrl(sub.public ? sub.public.photo : null) || true} avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
title={sub.public ? sub.public.fn : null} /> title={sub.public ? sub.public.fn : null} />
</div> </div>
); );
+10 -9
View File
@@ -4,7 +4,7 @@ import SendMessage from '../widgets/send-message.jsx';
import { REM_SIZE } from '../config.js'; import { REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js'; import { fitImageSize } from '../lib/blob-helpers.js';
import { bytesToHumanSize } from '../lib/strformat.js'; import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
export default class ImagePreview extends React.PureComponent { export default class ImagePreview extends React.PureComponent {
constructor(props) { constructor(props) {
@@ -45,34 +45,35 @@ export default class ImagePreview extends React.PureComponent {
size.maxWidth = '100%'; size.maxWidth = '100%';
size.maxHeight = '100%'; size.maxHeight = '100%';
let filename = this.props.content.filename;
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width. // Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12); const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
if (filename.length > maxlength) { const fname = shortenFileName(this.props.content.name, maxlength) || '-';
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
}
const width = this.props.content.width || '-'; const width = this.props.content.width || '-';
const height = this.props.content.height || '-'; const height = this.props.content.height || '-';
return ( return (
<div id="image-preview"> <div id="image-preview">
<div id="image-preview-caption-panel"> <div id="image-preview-caption-panel">
{!this.props.onSendMessage ? {!this.props.onSendMessage ?
<a href={this.props.content.url} download={this.props.content.filename}> <a href={this.props.content.url} download={this.props.content.name}>
<i className="material-icons">file_download</i> <FormattedMessage <i className="material-icons">file_download</i> <FormattedMessage
id="download_action" defaultMessage="download" description="Call to action [download]" /> id="download_action" defaultMessage="download" description="Call to action [download]" />
</a> </a>
: :
<span>{this.props.content.filename}</span> <span>{fname}</span>
} }
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a> <a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div> </div>
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}> <div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.filename} /> <img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.name} />
</div> </div>
{this.props.onSendMessage ? {this.props.onSendMessage ?
<SendMessage <SendMessage
messagePrompt="add_image_caption" messagePrompt="add_image_caption"
acceptBlank={true} acceptBlank={true}
tinode={this.props.tinode}
reply={this.props.reply}
onCancelReply={this.props.onCancelReply}
onSendMessage={this.handleSendImage} onSendMessage={this.handleSendImage}
onError={this.props.onError} /> onError={this.props.onError} />
: :
@@ -80,7 +81,7 @@ export default class ImagePreview extends React.PureComponent {
<div> <div>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:" <div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b></div> description="Label for a file name" /></b></div>
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div> <div><span title={this.props.content.name}>{fname}</span></div>
</div> </div>
<div> <div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:" <div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
+46 -41
View File
@@ -1,6 +1,5 @@
// In-place text editor. Shows text with an icon which toggles it into an input field. // In-place text editor. Shows text with an icon which toggles it into an input field.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import VisiblePassword from './visible-password.jsx'; import VisiblePassword from './visible-password.jsx';
@@ -8,6 +7,8 @@ export default class InPlaceEdit extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.selfRef = React.createRef();
this.state = { this.state = {
active: props.active, active: props.active,
initialValue: props.value || '', initialValue: props.value || '',
@@ -34,7 +35,7 @@ export default class InPlaceEdit extends React.Component {
} }
handeTextChange(e) { handeTextChange(e) {
this.setState({value: e.target.value}); this.setState({value: e.target.value || ''});
} }
handleKeyDown(e) { handleKeyDown(e) {
@@ -49,19 +50,22 @@ export default class InPlaceEdit extends React.Component {
handleStartEditing() { handleStartEditing() {
if (!this.props.readOnly) { if (!this.props.readOnly) {
ReactDOM.findDOMNode(this).focus(); this.setState({active: true}, () => {
this.setState({active: true}); if (this.selfRef.current) {
this.selfRef.current.focus();
}
});
} }
} }
handleEditingFinished(event) { handleEditingFinished(event) {
if (this.props.required && !event.target.checkValidity()) { const value = this.state.value.trim();
if (this.props.required && (!event.target.checkValidity() || !value)) {
// Empty input // Empty input
this.setState({value: this.props.value, active: false}); this.setState({value: this.props.value, active: false});
return; return;
} }
this.setState({active: false}); this.setState({active: false});
let value = this.state.value.trim();
if ((value || this.props.value) && (value !== this.props.value)) { if ((value || this.props.value) && (value !== this.props.value)) {
this.props.onFinished(value); this.props.onFinished(value);
} }
@@ -75,46 +79,47 @@ export default class InPlaceEdit extends React.Component {
} }
render() { render() {
if (this.state.active) { if (!this.state.active) {
var fieldType = this.props.type || 'text'; let spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
} else { let spanClass = 'in-place-edit' + (this.props.readOnly ? ' disabled' : '');
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
var spanClass = 'in-place-edit' +
(this.props.readOnly ? ' disabled' : '');
if (!spanText) { if (!spanText) {
spanText = this.props.placeholder; spanText = this.props.placeholder;
spanClass += ' placeholder'; spanClass += ' placeholder';
} }
if (spanText.length > 20) { if (!this.props.multiline || this.props.multiline == 1) {
// FIXME: this is wrong for RTL languages. spanClass += ' short';
spanText = spanText.substring(0, 19) + '...';
} }
return (<span className={spanClass} onClick={this.handleStartEditing}>
<span>{spanText}</span>
</span>);
} }
return (
this.state.active ? let element;
(fieldType == 'password' ? const attr = {};
<VisiblePassword if (this.props.type == 'password') {
value={this.state.value} element = VisiblePassword;
placeholder={this.props.placeholder} attr.onFinished = this.handlePasswordFinished;
required={this.props.required ? 'required' : ''} } else {
autoComplete={this.props.autoComplete} if (this.props.multiline > 1) {
autoFocus={true} element = 'textarea';
onFinished={this.handlePasswordFinished}/> attr.rows = this.props.multiline;
: attr.className = 'inplace-edit';
<input type={fieldType} } else {
value={this.state.value} element = 'input';
placeholder={this.props.placeholder} attr.type = this.props.type || 'text';
required={this.props.required ? 'required' : ''} }
autoComplete={this.props.autoComplete} attr.value = this.state.value;
autoFocus attr.onChange = this.handeTextChange;
onChange={this.handeTextChange} attr.onKeyDown = this.handleKeyDown;
onKeyDown={this.handleKeyDown} attr.onBlur = this.handleEditingFinished;
onBlur={this.handleEditingFinished} /> }
) attr.placeholder = this.props.placeholder;
: attr.required = this.props.required ? 'required' : '';
<span className={spanClass} onClick={this.handleStartEditing}> attr.autoComplete = this.props.autoComplete;
<span className="content">{spanText}</span> attr.autoFocus = true;
</span> attr.ref = this.selfRef;
);
return React.createElement(element, attr, null);
} }
}; };
+42
View File
@@ -0,0 +1,42 @@
// Image with a placeholder which is replaced when the promise is resolved.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class LazyImage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
src: 'img/placeholder.png',
style: Object.assign({padding: '4px'}, this.props.style),
className: this.props.className,
alt: this.props.alt,
onClick: this.props.onClick,
};
}
componentDidMount() {
// whenDone is a wrapper around an actual promise to be able to cancel it.
this.props.whenDone
.promise
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
.catch(() => this.setState({src: 'img/broken_image.png'}));
}
componentWillUnmount() {
this.props.whenDone.cancel();
}
componentDidUpdate(prevProps) {
if (prevProps.whenDone != this.props.whenDone) {
this.setState({src: 'img/placeholder.png', style: {...this.state.style, padding: '4px'}});
this.props.whenDone
.then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}}))
.catch(() => this.setState({src: 'img/broken_image.png'}));
}
}
render() {
return React.createElement('img', this.state);
}
};
+6 -5
View File
@@ -2,15 +2,15 @@ import React from 'react';
import Tinode from 'tinode-sdk'; import Tinode from 'tinode-sdk';
import { stringHash } from '../lib/strformat.js'; import { idToColorClass } from '../lib/strformat.js';
import { sanitizeImageUrl } from '../lib/utils.js';
export default class LetterTile extends React.PureComponent { export default class LetterTile extends React.PureComponent {
render() { render() {
let avatar; let avatar;
if (this.props.avatar === true) { if (this.props.avatar === true) {
const isGroup = (Tinode.topicType(this.props.topic) == 'grp'); const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
const iconColor = (isGroup ? 'light-color' : 'dark-color') const iconColor = idToColorClass(this.props.topic, isGroup);
+ (Math.abs(stringHash(this.props.topic)) % 16);
if (this.props.topic && this.props.title && this.props.title.trim()) { if (this.props.topic && this.props.title && this.props.title.trim()) {
const letter = this.props.title.trim().charAt(0); const letter = this.props.title.trim().charAt(0);
const className = 'lettertile ' + iconColor; const className = 'lettertile ' + iconColor;
@@ -21,9 +21,10 @@ export default class LetterTile extends React.PureComponent {
<i className={className}>group</i> : <i className={className}>person</i>; <i className={className}>group</i> : <i className={className}>person</i>;
} }
} else if (this.props.avatar) { } else if (this.props.avatar) {
const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
// If avatar image is invalid, show a placeholder. // If avatar image is invalid, show a placeholder.
avatar = <img className="avatar" alt="avatar" src={this.props.avatar} avatar = <img className="avatar" alt="avatar" src={url}
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />; onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
} else { } else {
avatar = null; avatar = null;
} }
+3 -1
View File
@@ -2,7 +2,9 @@ import React from 'react';
export default class LoadSpinner extends React.PureComponent { export default class LoadSpinner extends React.PureComponent {
render() { render() {
const className = 'load-spinner-box' + (this.props.large ? ' large' : '')
+ (this.props.clear ? ' clear' : '') + (this.props.centered ? ' centered' : '');
return (this.props.show ? return (this.props.show ?
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null); <div className={className}><div className="loader-spinner"></div></div> : null);
} }
} }
-29
View File
@@ -1,29 +0,0 @@
// Toggle [Title text >] -> [Title text v]
import React from 'react';
export default class MoreButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
open: props.open
};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle() {
const open = !this.state.open;
this.setState({open: open});
if (this.props.onToggle) {
this.props.onToggle(open);
}
}
render() {
return (<label className="small clean-clickable" onClick={this.handleToggle}>{this.props.title}...
{this.state.open ? <i className="material-icons">expand_more</i> :
<i className="material-icons">chevron_right</i>}
</label>);
}
}
+40 -20
View File
@@ -5,34 +5,36 @@ import AvatarUpload from './avatar-upload.jsx';
import CheckBox from './checkbox.jsx'; import CheckBox from './checkbox.jsx';
import TagManager from './tag-manager.jsx'; import TagManager from './tag-manager.jsx';
import { MAX_TITLE_LENGTH } from '../config.js'; import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
export default class NewTopicGroup extends React.PureComponent { export default class NewTopicGroup extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.fullName = React.createRef();
this.state = { this.state = {
fn: '', // full/formatted name fullName: '', // full/formatted name
private: '', private: '',
description: '',
imageDataUrl: null, imageDataUrl: null,
tags: [], tags: [],
isChannel: false isChannel: false
}; };
this.handleFnChange = this.handleFnChange.bind(this); this.handleFieldEdit = this.handleFieldEdit.bind(this);
this.handlePrivateChange = this.handlePrivateChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this); this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this); this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleChannelToggle = this.handleChannelToggle.bind(this); this.handleChannelToggle = this.handleChannelToggle.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
handleFnChange(e) { componentDidMount() {
this.setState({fn: e.target.value}); // this.fullName.current.focus();
} }
handlePrivateChange(e) { handleFieldEdit(name, e) {
this.setState({private: e.target.value}); this.setState({[name]: e.target.value || ''});
} }
handleImageChanged(img) { handleImageChanged(img) {
@@ -50,10 +52,11 @@ export default class NewTopicGroup extends React.PureComponent {
handleSubmit(e) { handleSubmit(e) {
e.preventDefault(); e.preventDefault();
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH); const fn = this.state.fullName.trim().substring(0, MAX_TITLE_LENGTH);
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH); const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
const description = this.state.description.trim().substring(0, MAX_TOPIC_DESCRIPTION_LENGTH);
if (fn) { if (fn) {
this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags, this.state.isChannel); this.props.onSubmit(fn, description, this.state.imageDataUrl, comment, this.state.tags, this.state.isChannel);
} }
} }
@@ -64,8 +67,14 @@ export default class NewTopicGroup extends React.PureComponent {
} }
return ( return (
<form className="panel-form" onSubmit={this.handleSubmit}> <form className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column"> <div className="panel-form-column">
<center>
<AvatarUpload
tinode={this.props.tinode}
onError={this.props.onError}
onImageChanged={this.handleImageChanged} />
</center>
<div className="group">
<label className="small" htmlFor="new-topic-fn"> <label className="small" htmlFor="new-topic-fn">
<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" />
@@ -73,9 +82,11 @@ export default class NewTopicGroup extends React.PureComponent {
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group" <FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
description="Prompt for entering topic name">{ description="Prompt for entering topic name">{
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder} (placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
value={this.state.fn} onChange={this.handleFnChange} autoFocus required /> ref={this.fullName} value={this.state.fullName} onChange={this.handleFieldEdit.bind(this, 'fullName')}
autoFocus required tabIndex={0} />
}</FormattedMessage> }</FormattedMessage>
<br /> </div>
<div className="group">
<label className="small" htmlFor="new-topic-priv"> <label className="small" htmlFor="new-topic-priv">
<FormattedMessage id="label_private" defaultMessage="Private comment" <FormattedMessage id="label_private" defaultMessage="Private comment"
description="Label for editing 'private'" /> description="Label for editing 'private'" />
@@ -83,26 +94,35 @@ export default class NewTopicGroup extends React.PureComponent {
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only" <FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{ description="Placeholder for editing 'private'">{
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder} (placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
value={this.state.private} onChange={this.handlePrivateChange} /> value={this.state.private} onChange={this.handleFieldEdit.bind(this, 'private')} tabIndex={1} />
}</FormattedMessage>
</div>
<div className="group">
<label className="small" htmlFor="new-topic-desc">
<FormattedMessage id="label_description" defaultMessage="Description"
description="Label for editing topic description" />
</label>
<FormattedMessage id="description_editing_placeholder" defaultMessage="Description (optional)"
description="Placeholder for editing topic description">{
(placeholder) => <input type="text" id="new-topic-desc" placeholder={placeholder}
value={this.state.description} onChange={this.handleFieldEdit.bind(this, 'description')} tabIndex={2} />
}</FormattedMessage> }</FormattedMessage>
</div> </div>
<AvatarUpload
onError={this.props.onError}
onImageChanged={this.handleImageChanged} />
</div> </div>
<div className="panel-form-row"> <div className="panel-form-row">
<CheckBox checked={this.state.isChannel} onChange={this.handleChannelToggle}/>&nbsp; <CheckBox checked={this.state.isChannel} tabIndex={3} onChange={this.handleChannelToggle} />&nbsp;
<label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt" <label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt"
defaultMessage="This is a channel" defaultMessage="This is a channel"
description="Checkbox label when creating a channel" /></label> description="Checkbox label when creating a channel" /></label>
</div> </div>
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)" <FormattedMessage id="title_tag_manager" defaultMessage="Tags (search & discovery)"
description="Section title for TagManager">{ description="Section title for TagManager">{
(title) => <TagManager (title) => <TagManager
tinode={this.props.tinode}
tags={this.state.tags} tags={this.state.tags}
activated={true} activated={true}
onTagsChanged={this.handleTagsChanged} onTagsChanged={this.handleTagsChanged}
tinode={this.props.tinode} tabIndex={4}
title={title} /> title={title} />
}</FormattedMessage> }</FormattedMessage>
<div className="dialog-buttons"> <div className="dialog-buttons">
+8 -6
View File
@@ -6,7 +6,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import CheckBox from './checkbox.jsx'; import CheckBox from './checkbox.jsx';
import Contact from './contact.jsx'; import Contact from './contact.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
// Translatable permission names. // Translatable permission names.
const messages = defineMessages({ const messages = defineMessages({
@@ -67,7 +67,7 @@ class PermissionsEditor extends React.Component {
handleChange(val) { handleChange(val) {
let mode = this.state.mode; let mode = this.state.mode;
let idx = mode.indexOf(val); const idx = mode.indexOf(val);
if (idx == -1) { if (idx == -1) {
mode += val; mode += val;
} else { } else {
@@ -78,8 +78,8 @@ class PermissionsEditor extends React.Component {
handleSubmit() { handleSubmit() {
// Normalize string, otherwise cannot check if mode has changed. // Normalize string, otherwise cannot check if mode has changed.
var mode = (this.state.mode || 'N').split('').sort().join(''); const mode = (this.state.mode || 'N').split('').sort().join('');
var before = (this.props.mode || 'N').split('').sort().join('') const before = (this.props.mode || 'N').split('').sort().join('')
if (mode !== before) { if (mode !== before) {
this.props.onSubmit(mode); this.props.onSubmit(mode);
} else { } else {
@@ -132,10 +132,12 @@ class PermissionsEditor extends React.Component {
return ( return (
<div className="panel-form-column"> <div className="panel-form-column">
{this.props.userTitle ? {this.props.userTitle ?
<ul className="contact-box"><Contact <ul className="contact-box small"><Contact
tinode={this.props.tinode}
item={this.props.item} item={this.props.item}
title={this.props.userTitle} title={this.props.userTitle}
avatar={makeImageDataUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null} small={true}
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
<label className="small"><FormattedMessage id="title_permissions" <label className="small"><FormattedMessage id="title_permissions"
defaultMessage="Permissions" description="Section title"/></label> defaultMessage="Permissions" description="Section title"/></label>
<table className="permission-editor"> <table className="permission-editor">
+6 -8
View File
@@ -57,16 +57,14 @@ export default class SearchContacts extends React.PureComponent {
<div className="panel-form"> <div className="panel-form">
<div className="panel-form-row"> <div className="panel-form-row">
<i className="material-icons search">search</i> <i className="material-icons search">search</i>
<FormattedMessage id="search_placeholder" defaultMessage="List like email:alice@example.com, tel:17025550003..." <input className="search" type="text"
description="Placeholder in contacts search field">{ placeholder={this.props.placeholder}
(search_placeholder) => <input className="search" type="text"
placeholder={search_placeholder}
value={this.state.search} onChange={this.handleSearchChange} value={this.state.search} onChange={this.handleSearchChange}
onKeyDown={this.handleKeyDown} required autoFocus /> onKeyDown={this.handleKeyDown} required autoFocus />
}</FormattedMessage> {this.state.search ?
<a href="#" onClick={this.handleClear}> <a href="#" onClick={this.handleClear}><i className="material-icons">highlight_off</i></a>
<i className="material-icons">close</i> :
</a> <span><i className="material-icons">&nbsp;</i></span>}
</div> </div>
</div> </div>
); );
+42 -2
View File
@@ -5,6 +5,7 @@ import { Drafty } from 'tinode-sdk';
import { KEYPRESS_DELAY } from '../config.js'; import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js'; import { filePasted } from '../lib/blob-helpers.js';
import { quoteFormatter, replyFormatter } from '../lib/formatters.js';
const messages = defineMessages({ const messages = defineMessages({
messaging_disabled: { messaging_disabled: {
@@ -50,12 +51,18 @@ class SendMessage extends React.PureComponent {
this.handleSend = this.handleSend.bind(this); this.handleSend = this.handleSend.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleMessageTyping = this.handleMessageTyping.bind(this); this.handleMessageTyping = this.handleMessageTyping.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.formatReply = this.formatReply.bind(this);
} }
componentDidMount() { componentDidMount() {
if (this.messageEditArea) { if (this.messageEditArea) {
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false); this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
} }
this.setState({quote: this.formatReply()});
} }
componentWillUnmount() { componentWillUnmount() {
@@ -64,10 +71,22 @@ class SendMessage extends React.PureComponent {
} }
} }
componentDidUpdate() { componentDidUpdate(prevProps) {
if (this.messageEditArea) { if (this.messageEditArea) {
this.messageEditArea.focus(); this.messageEditArea.focus();
} }
if (prevProps.reply != this.props.reply) {
this.setState({quote: this.formatReply()});
}
}
formatReply() {
return this.props.reply ?
Drafty.format(this.props.reply.content, replyFormatter, {
formatMessage: this.props.intl.formatMessage.bind(this.props.intl),
authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode)
}) : null;
} }
handlePasteEvent(e) { handlePasteEvent(e) {
@@ -141,6 +160,15 @@ class SendMessage extends React.PureComponent {
this.setState(newState); this.setState(newState);
} }
handleQuoteClick(e) {
e.preventDefault();
e.stopPropagation();
if (this.props.reply && this.props.onQuoteClick) {
const replyToSeq = this.props.reply.seq;
this.props.onQuoteClick(replyToSeq);
}
}
render() { render() {
const { formatMessage } = this.props.intl; const { formatMessage } = this.props.intl;
const prompt = this.props.disabled ? const prompt = this.props.disabled ?
@@ -148,7 +176,18 @@ class SendMessage extends React.PureComponent {
(this.props.messagePrompt ? (this.props.messagePrompt ?
formatMessage(messages[this.props.messagePrompt]) : formatMessage(messages[this.props.messagePrompt]) :
formatMessage(messages.type_new_message)); formatMessage(messages.type_new_message));
const quote =
(<div id="reply-quote-preview">
<div className="cancel">
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
</div>
{this.state.quote}
</div>);
return ( return (
<div id="send-message-wrapper">
{this.state.quote && !this.props.noInput ? quote : null}
<div id="send-message-panel"> <div id="send-message-panel">
{!this.props.disabled ? {!this.props.disabled ?
<> <>
@@ -164,7 +203,7 @@ class SendMessage extends React.PureComponent {
: :
null} null}
{this.props.noInput ? {this.props.noInput ?
<div className="hr thin" /> : (this.state.quote ? quote : <div className="hr thin" />) :
<textarea id="sendMessage" placeholder={prompt} <textarea id="sendMessage" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping} value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
@@ -182,6 +221,7 @@ class SendMessage extends React.PureComponent {
<div id="writing-disabled">{prompt}</div> <div id="writing-disabled">{prompt}</div>
} }
</div> </div>
</div>
); );
} }
}; };
+17 -3
View File
@@ -2,24 +2,38 @@ import React from 'react';
import LetterTile from './letter-tile.jsx'; import LetterTile from './letter-tile.jsx';
import ButtonBack from './button-back.jsx'; import ButtonBack from './button-back.jsx';
import ContactBadges from './contact-badges.jsx';
import MenuContacts from './menu-contacts.jsx'; import MenuContacts from './menu-contacts.jsx';
import MenuStart from './menu-start.jsx'; import MenuStart from './menu-start.jsx';
import { sanitizeImageUrl } from '../lib/utils.js';
export default class SideNavbar extends React.PureComponent { export default class SideNavbar extends React.PureComponent {
render() { render() {
const icon_badges = [];
if (this.props.trustedBadges) {
this.props.trustedBadges.map((b) => {
icon_badges.push({icon: b, color: 'badge-inv'});
});
}
let avatar = null;
if (this.props.tinode) {
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
}
return ( return (
<div id="side-caption-panel" className="caption-panel"> <div id="side-caption-panel" className="caption-panel">
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null} {this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
{this.props.avatar ? {avatar ?
<div id="self-avatar" className="avatar-box"> <div id="self-avatar" className="avatar-box">
<LetterTile <LetterTile
avatar={this.props.avatar} tinode={this.props.tinode}
avatar={avatar}
topic={this.props.myUserId} topic={this.props.myUserId}
title={this.props.title} /> title={this.props.title} />
</div> </div>
: :
null} null}
<div id="sidepanel-title" className="panel-title">{this.props.title}</div> <div id="sidepanel-title" className="panel-title">{this.props.title}<ContactBadges badges={icon_badges}/></div>
{this.props.state === 'login' ? {this.props.state === 'login' ?
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> : <MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
this.props.state === 'contacts' ? this.props.state === 'contacts' ?
+8 -5
View File
@@ -12,7 +12,7 @@ export default class TagManager extends React.Component {
super(props); super(props);
this.state = { this.state = {
tags: this.props.tags, tags: this.props.tags || [],
tagInput: '', tagInput: '',
activated: this.props.activated activated: this.props.activated
}; };
@@ -25,8 +25,9 @@ export default class TagManager extends React.Component {
} }
static getDerivedStateFromProps(nextProps, prevState) { static getDerivedStateFromProps(nextProps, prevState) {
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) { const tags = nextProps.tags || [];
return {tags: nextProps.tags}; if (!arrayEqual(tags, prevState.tags) && !prevState.activated) {
return {tags: tags};
} }
return null; return null;
} }
@@ -75,11 +76,11 @@ export default class TagManager extends React.Component {
handleSubmit() { handleSubmit() {
// Add unprocessed input to tags, submit the list. // Add unprocessed input to tags, submit the list.
this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim())); this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim()));
this.setState({activated: false, tags: this.props.tags}); this.setState({activated: false, tags: this.props.tags || []});
} }
handleCancel() { handleCancel() {
this.setState({activated: false, tagInput: '', tags: this.props.tags}); this.setState({activated: false, tagInput: '', tags: this.props.tags || []});
if (this.props.onCancel) { if (this.props.onCancel) {
this.props.onCancel(); this.props.onCancel();
} }
@@ -116,9 +117,11 @@ export default class TagManager extends React.Component {
<FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags" <FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags"
description="Tag editor prompt when no tags are found.">{ description="Tag editor prompt when no tags are found.">{
(add_tags_prompt) => <ChipInput (add_tags_prompt) => <ChipInput
tinode={this.props.tinode}
chips={tags} chips={tags}
avatarDisabled={true} avatarDisabled={true}
prompt={add_tags_prompt} prompt={add_tags_prompt}
tabIndex={this.props.tabIndex}
onEnter={this.handleAddTag} onEnter={this.handleAddTag}
onFocusLost={this.handleAddTag} onFocusLost={this.handleAddTag}
onCancel={this.handleCancel} onCancel={this.handleCancel}
+180
View File
@@ -0,0 +1,180 @@
// View for editing topic (including 'me' topic) parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import TopicDescEdit from '../widgets/topic-desc-edit.jsx';
import { arrayEqual, asEmail, asPhone } from '../lib/utils.js';
export default class TopicCommon 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.topic == 'me',
owner: acs && acs.isOwner(),
credentials: (topic.getCredentials ? topic.getCredentials() : null) || [],
addCredActive: false,
addCredInvalid: false,
newCred: '',
tags: []
};
this.previousTagsUpdated = undefined;
this.onTagsUpdated = this.onTagsUpdated.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
this.handleCredChange = this.handleCredChange.bind(this);
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
this.handleCredEntered = this.handleCredEntered.bind(this);
}
// No need to separately handle component mount.
componentDidUpdate(props) {
const topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
topic.onCredsUpdated = this.tnCredsUpdated;
if (topic.onTagsUpdated != this.onTagsUpdated) {
if (topic.getType() == 'grp') {
this.previousTagsUpdated = topic.onTagsUpdated;
topic.onTagsUpdated = this.onTagsUpdated;
} else {
this.previousTagsUpdated = undefined;
}
}
if (this.state.topic != props.topic) {
this.setState({topic: props.topic});
}
}
componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onCredsUpdated = undefined;
topic.onTagsUpdated = this.previousTagsUpdated;
}
tnCredsUpdated(creds) {
this.setState({credentials: creds || []});
}
handleCredChange(e) {
this.setState({newCred: e.target.value, addCredInvalid: false});
}
handleCredKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({newCred: '', addCredActive: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleCredEntered(e);
}
}
handleCredEntered(e) {
const value = this.state.newCred.trim();
if (!value) {
this.setState({addCredActive: false, addCredInvalid: false});
return;
}
let val = asPhone(value);
let method;
if (val) {
method = 'tel';
} else {
val = asEmail(value);
if (val) {
method = 'email';
}
}
if (method) {
this.props.onCredAdd(method, val);
this.setState({addCredActive: false, newCred: ''});
} else {
this.setState({addCredInvalid: true});
}
}
// Server informs that the tags have been updated.
onTagsUpdated(tags) {
this.setState({tags: tags});
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
this.previousTagsUpdated(tags);
}
}
// Request server to change tags.
handleTagsUpdated(tags) {
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
this.props.onTopicTagsUpdateRequest(this.props.topic, tags);
}
}
render() {
const credentials = [];
if (this.state.isMe) {
this.state.credentials.map((cred) => {
credentials.push(
<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
<span> {!cred.done ?
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
description="Validate credentail call to action" />
</a>
: 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 (
<div className="scrollable-panel">
<TopicDescEdit
tinode={this.props.tinode}
topic={this.props.topic}
onUpdateTopicDesc={this.props.onUpdateTopicDesc}
onUpdateTags={this.handleTagsUpdated}
onError={this.props.onError} />
{this.state.isMe ?
<>
<div className="hr" />
<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>
</>
: null
}
</div>
);
}
};
+285
View File
@@ -0,0 +1,285 @@
// 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, MAX_TOPIC_DESCRIPTION_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: Tinode.isMeTopicName(this.props.topic),
owner: acs && acs.isOwner(),
fullName: topic.public ? topic.public.fn : undefined,
private: topic.private ? topic.private.comment : null,
description: topic.public ? topic.public.note : undefined,
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
tags: topic.tags() || [],
newAvatar: null,
newAvatarMime: null
};
this.previousOnTags = null;
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.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
this.handleDescriptionUpdate = this.handleDescriptionUpdate.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);
this.previousOnTags = topic.onTagsUpdated;
topic.onTagsUpdated = this.tnNewTags;
}
componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onTagsUpdated = this.previousOnTags;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (fn && this.state.fullName !== fn) {
this.setState({fullName: fn});
this.props.onUpdateTopicDesc(this.props.topic, theCard(fn, null));
}
}
handlePrivateUpdate(comment) {
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
if (this.state.private !== comment) {
this.setState({private: comment});
this.props.onUpdateTopicDesc(this.props.topic, null, comment || Tinode.DEL_CHAR);
}
}
handleDescriptionUpdate(desc) {
desc = desc.trim().substring(0, MAX_TOPIC_DESCRIPTION_LENGTH);
if (desc) {
this.setState({description: desc});
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, null, null, desc));
}
}
// 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.setState({avatar: null});
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 = (image) => {
let {mime, blob, width, height} = image;
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)
.then(b64 => {
const du = makeImageUrl({data: b64.bits, 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)
.then(scaled => readyToUpload(scaled))
.catch(err => this.props.onError(err, 'err'));
} else {
readyToUpload({mime: mime, blob: blob, width: width, height: 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-column">
<center>
<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} />
</center>
{this.state.isMe ?
<div className="group">
<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>
:
<>
<div className="group">
<div><label className="small">
<FormattedMessage id="label_topic_name" defaultMessage="Name"
description="Label for editing topic name" />
</label></div>
<div>
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
description="Prompt for entering topic name">{
(group_name_placeholder) => <InPlaceEdit
placeholder={group_name_placeholder}
readOnly={!editable}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage>
</div>
</div>
<div className="group">
<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>
</>
}
{editable || this.state.description ?
<div className="group">
<div><label className="small">
<FormattedMessage id="label_description" defaultMessage="Description"
description="Label for editing topic description" />
</label></div>
<div>
<FormattedMessage id="description_editing_placeholder"
defaultMessage="Description (optional)"
description="Placeholder for editing topic description">{
(private_placeholder) => <InPlaceEdit
placeholder={private_placeholder}
readOnly={!editable}
value={this.state.description}
multiline={2}
onFinished={this.handleDescriptionUpdate} />
}</FormattedMessage>
</div>
</div>
: null
}
</div>
{editable ?
<>
<div className="hr" />
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (search & discovery)"
description="Section title for TagManager">{
(title_tag_manager) => <TagManager
tinode={this.props.tinode}
title={title_tag_manager}
activated={false}
tags={this.state.tags}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
</>
: null
}
</>
);
}
};
+266
View File
@@ -0,0 +1,266 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import InPlaceEdit from './in-place-edit.jsx';
import { NO_ACCESS_MODE } from '../config.js';
const messages = defineMessages({
clear_messages: {
id: 'action_clear_messages',
defaultMessage: 'Clear Messages',
description: 'Flat button [Clear Messages] (soft-delete messages)'
},
clear_messages_warning: {
id: 'clear_messages_warning',
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
description: 'Alert dialog warning when deleting all messages.'
},
delete_messages: {
id: 'action_delete_messages',
defaultMessage: 'Clear Messages for All',
description: 'Flat button [Clear for All] (hard-delete all messages)'
},
delete_messages_warning: {
id: 'delete_messages_warning',
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
description: 'Alert dialog warning when hard-deleting all messages.'
},
topic_delete: {
id: 'topic_delete',
defaultMessage: 'Delete Conversation',
description: 'Alert title when deleting the topic.'
},
topic_delete_warning: {
id: 'topic_delete_warning',
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
description: 'Alert warning when deleting entire topic'
},
leave_chat: {
id: 'action_leave_chat',
defaultMessage: 'Leave Conversation',
description: 'Flat button [Leave Conversation]'
},
leave_chat_warning: {
id: 'leave_chat_warning',
defaultMessage: 'Are you sure you want to leave this conversation?',
description: 'Alert dialog warning when unsubscribing from a chat.'
},
block_contact: {
id: 'action_block_contact',
defaultMessage: "Block Contact",
description: "Flat button [Block Contact]"
},
block_contact_warning: {
id: 'block_contact_warning',
defaultMessage: 'Are you sure you want to block this contact?',
description: 'Alert dialog warning when blocking a contact.'
},
report_chat: {
id: 'action_report_chat',
defaultMessage: 'Report Conversation',
description: 'Flat button [Report Group]'
},
report_chat_warning: {
id: 'report_chat_warning',
defaultMessage: 'Are you sure you want to block and report this conversation?',
description: 'Alert dialog warning when reporting a conversation for abuse'
},
other_user: {
id: 'label_other_user',
defaultMessage: 'Other',
description: 'Label for the other user when the user is unnamed'
},
});
class TopicSecurity extends React.PureComponent {
constructor(props) {
super(props);
this.handleDeleteTopic = this.handleDeleteTopic.bind(this);
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
this.handleLeave = this.handleLeave.bind(this);
this.handleBlock = this.handleBlock.bind(this);
this.handleReport = this.handleReport.bind(this);
}
handleDeleteTopic(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.topic_delete), // title
formatMessage(messages.topic_delete_warning), // content
(() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleDeleteMessages(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(this.props.deleter ? messages.delete_messages : messages.clear_messages), // title
formatMessage(this.props.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleLeave(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.leave_chat), // title
formatMessage(messages.leave_chat_warning), // content
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleBlock(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.block_contact), // title
formatMessage(messages.block_contact_warning), // content
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleReport(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.report_chat), // title
formatMessage(messages.report_chat_warning), // content
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
render() {
const {formatMessage} = this.props.intl;
return (
<div className="scrollable-panel">
<div className="panel-form-column">
{!this.props.channel ?
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
<i className="material-icons">delete_outline</i> &nbsp;{
formatMessage(this.props.deleter ? messages.delete_messages : messages.clear_messages)
}
</a>
:
null
}
{this.props.owner ?
<a href="#" className="danger flat-button" onClick={this.handleDeleteTopic}>
<i className="material-icons">delete</i> &nbsp;{formatMessage(messages.topic_delete)}
</a>
:
<a href="#" className="danger flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> &nbsp;{formatMessage(messages.leave_chat)}
</a>
}
{!this.props.groupTopic ?
<a href="#" className="danger flat-button" onClick={this.handleBlock}>
<i className="material-icons">block</i> &nbsp;{formatMessage(messages.block_contact)}
</a>
:
null
}
{!this.props.owner ?
<a href="#" className="danger flat-button" onClick={this.handleReport}>
<i className="material-icons">report</i> &nbsp;{formatMessage(messages.report_chat)}
</a>
:
null
}
</div>
<div className="hr" />
<div className="panel-form-column">
{this.props.groupTopic ?
<>
<div className="group">
<label>
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:"
description="Label for current user permissions" />
</label> <tt className="clickable"
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('want')}}>
{this.props.access}
</tt>
</div>
{!this.props.channel ?
<div className="group">
<div>
<label className="small">
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
description="Label for default access mode" />
</label>
</div>
<div className="quoted">
<div>Auth: <tt className={this.props.owner ? 'clickable' : null}
onClick={(e) => {
e.preventDefault();
if (this.props.owner) {
this.props.onLaunchPermissionsEditor('auth');
}
}}>{this.props.auth}</tt>
</div>
<div>Anon: <tt className={this.props.owner ? 'clickable' : null}
onClick={(e) => {
e.preventDefault();
if (this.props.owner) {
this.props.onLaunchPermissionsEditor('anon');
}
}}>{this.props.anon}</tt>
</div>
</div>
</div>
:
null
}
</>
:
<div className="group">
<div>
<label className="small">
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>
<FormattedMessage id="label_you" defaultMessage="You:"
description="Label for the current user" /> <tt className="clickable"
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('want')}}>
{this.props.access}
</tt>
</div>
<div>{this.props.fullName ? this.props.fullName : formatMessage(messages.other_user)}:
&nbsp;<tt className="clickable"
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('given')}}>
{this.props.modeGiven2}
</tt>
</div>
</div>
</div>
}
</div>
</div>
);
}
};
export default injectIntl(TopicSecurity);
+3723 -1717
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long