Merge branch 'next' into features
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// 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,
|
||||
(mime, blob, width, height) => {
|
||||
this.props.onSubmit(mime, blob, width, height);
|
||||
},
|
||||
(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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,42 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import LoadSpinner from './load-spinner.jsx';
|
||||
|
||||
import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js';
|
||||
import { imageScaled, blobToBase64, makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||
|
||||
import { AVATAR_SIZE } from '../config.js';
|
||||
|
||||
export default class AvatarUpload extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
dataUrl: props.avatar
|
||||
source: props.avatar
|
||||
};
|
||||
|
||||
this.handleFileUpload = this.handleFileUpload.bind(this);
|
||||
this.handleFileReceived = this.handleFileReceived.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.avatar != prevProps.avatar) {
|
||||
this.setState({dataUrl: this.props.avatar})
|
||||
this.setState({source: this.props.avatar});
|
||||
}
|
||||
}
|
||||
|
||||
handleFileUpload(e) {
|
||||
imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
|
||||
// Image successfully scaled and converted.
|
||||
(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');
|
||||
});
|
||||
handleFileReceived(e) {
|
||||
const image = e.target.files[0];
|
||||
this.props.onImageUpdated(image.type, URL.createObjectURL(image), image.name);
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
e.target.value = '';
|
||||
}
|
||||
@@ -48,16 +38,17 @@ export default class AvatarUpload extends React.Component {
|
||||
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
|
||||
return (
|
||||
<div className={className}>
|
||||
{this.props.readOnly || !this.state.dataUrl ?
|
||||
{this.props.readOnly || !this.state.source ?
|
||||
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>
|
||||
</a>}
|
||||
{this.state.dataUrl ?
|
||||
<img src={this.state.dataUrl} className="preview" /> :
|
||||
{this.state.source ?
|
||||
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
|
||||
this.props.readOnly && this.props.uid ?
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={true}
|
||||
topic={this.props.uid}
|
||||
title={this.props.title} />
|
||||
@@ -66,11 +57,12 @@ export default class AvatarUpload extends React.Component {
|
||||
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
||||
{this.props.readOnly ? null :
|
||||
<input type="file" id={randId} className="inputfile hidden"
|
||||
accept="image/*" onChange={this.handleFileUpload} />}
|
||||
accept="image/*" onChange={this.handleFileReceived} />}
|
||||
{this.props.readOnly ? null :
|
||||
<label htmlFor={randId} className="round">
|
||||
<i className="material-icons">file_upload</i>
|
||||
</label>}
|
||||
<LoadSpinner show={this.props.uploading} large={true} clear={true} centered={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -1,15 +1,15 @@
|
||||
// Single message, sent or received.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import Attachment from './attachment.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import ReceivedMarker from './received-marker.jsx'
|
||||
import UploadingImage from './uploading-image.jsx'
|
||||
|
||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
||||
|
||||
export default class ChatMessage extends React.Component {
|
||||
class BaseChatMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -25,6 +25,8 @@ export default class ChatMessage extends React.Component {
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
}
|
||||
|
||||
handleImagePreview(e) {
|
||||
@@ -60,12 +62,13 @@ export default class ChatMessage extends React.Component {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
||||
if (this.props.received >= Tinode.MESSAGE_STATUS_FAILED &&
|
||||
if (this.props.userIsWriter &&
|
||||
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
|
||||
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
|
||||
menuItems.push('menu_item_reply');
|
||||
}
|
||||
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
||||
y: e.pageY, x: e.pageX }, menuItems);
|
||||
y: e.pageY, x: e.pageX, pickReply: this.props.pickReply }, menuItems);
|
||||
}
|
||||
|
||||
handleProgress(ratio) {
|
||||
@@ -76,6 +79,15 @@ export default class ChatMessage extends React.Component {
|
||||
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() {
|
||||
const sideClass = this.props.deleted ? 'center' :
|
||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||
@@ -105,7 +117,8 @@ export default class ChatMessage extends React.Component {
|
||||
onError={this.props.onError}
|
||||
key={i} />);
|
||||
}, this);
|
||||
content = React.createElement(React.Fragment, null, Drafty.format(content, draftyFormatter, this));
|
||||
const tree = Drafty.format(content, this.props.formatter, this);
|
||||
content = React.createElement(React.Fragment, null, tree);
|
||||
} else if (this.props.deleted) {
|
||||
// Message represents a range of deleted messages.
|
||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||
@@ -113,19 +126,18 @@ export default class ChatMessage extends React.Component {
|
||||
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
||||
</i></>
|
||||
} else if (typeof content != 'string') {
|
||||
content = <>
|
||||
<i className="material-icons gray">error_outline</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when message is unreadable" /></i>
|
||||
</>
|
||||
content = <><i className="material-icons gray">error_outline</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when message is unreadable" /></i></>
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={sideClass}>
|
||||
<li ref={this.props.innerRef} className={sideClass}>
|
||||
{this.props.userFrom && this.props.response ?
|
||||
<div className="avatar-box">
|
||||
{fullDisplay ?
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
topic={this.props.userFrom}
|
||||
title={this.props.userName}
|
||||
avatar={avatar} /> :
|
||||
@@ -156,10 +168,10 @@ export default class ChatMessage extends React.Component {
|
||||
</div>
|
||||
{fullDisplay ?
|
||||
<div className="author">
|
||||
<FormattedMessage id="user_not_found" defaultMessage="Not found"
|
||||
description="In place of a user's full name when the user is not found.">{
|
||||
(notFound) => {return this.props.userName || <i>{notFound}</i>}
|
||||
}</FormattedMessage>
|
||||
{this.props.userName ||
|
||||
<i><FormattedMessage id="user_not_found" defaultMessage="Not found"
|
||||
description="In place of a user's full name when the user is not found." /></i>
|
||||
}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
@@ -169,86 +181,7 @@ export default class ChatMessage extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
// Convert Drafty object to a tree of React elements.
|
||||
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||
const IntlChatMessage = injectIntl(BaseChatMessage);
|
||||
const ChatMessage = React.forwardRef((props, ref) => <IntlChatMessage innerRef = {ref} {...props} />);
|
||||
|
||||
// Converts Drafty elements into React classes.
|
||||
// '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;
|
||||
}
|
||||
};
|
||||
export default ChatMessage;
|
||||
|
||||
@@ -15,9 +15,11 @@ export default class CheckBox extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
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_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 ?
|
||||
<i className="material-icons">check_box</i> :
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import Chip from './chip.jsx';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
/* BEGIN ChipInput: group membership widget */
|
||||
export default class ChipInput extends React.Component {
|
||||
@@ -118,8 +118,9 @@ export default class ChipInput extends React.Component {
|
||||
this.state.sortedChips.map((item) => {
|
||||
chips.push(
|
||||
<Chip
|
||||
tinode={this.props.tinode}
|
||||
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}
|
||||
noAvatar={this.props.avatarDisabled}
|
||||
topic={item.user}
|
||||
@@ -131,6 +132,7 @@ export default class ChipInput extends React.Component {
|
||||
count++;
|
||||
});
|
||||
const className = "chip-input" + (this.state.focused ? ' focused' : '');
|
||||
const autoFocus = !(this.props.tabIndex > 0);
|
||||
return (
|
||||
<div className={className}>
|
||||
{chips}
|
||||
@@ -141,7 +143,8 @@ export default class ChipInput extends React.Component {
|
||||
onBlur={this.handleFocusLost}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
value={this.state.input}
|
||||
autoFocus />
|
||||
tabIndex={this.props.tabIndex}
|
||||
autoFocus={autoFocus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export default class Chip extends React.PureComponent {
|
||||
<span className="spacer" /> :
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.props.avatar || true}
|
||||
topic={this.props.topic}
|
||||
title={this.props.title} />
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
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] */
|
||||
export default class ContactBadges extends React.PureComponent {
|
||||
render() {
|
||||
let badges = null;
|
||||
if (this.props.badges && this.props.badges.length > 0) {
|
||||
badges = [];
|
||||
this.props.badges.map(function(b) {
|
||||
if (b.icon) {
|
||||
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>);
|
||||
} else {
|
||||
const style = 'badge' + (b.color ? ' ' + b.color : '');
|
||||
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
|
||||
}
|
||||
});
|
||||
return <>{badges}</>;
|
||||
}
|
||||
return null;
|
||||
render() {
|
||||
let badges = null;
|
||||
if (this.props.badges && this.props.badges.length > 0) {
|
||||
badges = [];
|
||||
this.props.badges.map((b) => {
|
||||
const color = (b.color ? ' ' + b.color : '');
|
||||
if (b.icon) {
|
||||
badges.push(<i className={'material-icons as-badge' + color} key={b.key || b.icon}>{icon_mapping[b.icon] || b.icon}</i>);
|
||||
} else {
|
||||
badges.push(<span className={'badge' + color} key={b.key || b.name}>{b.name}</span>);
|
||||
}
|
||||
});
|
||||
return <>{badges}</>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import Tinode from 'tinode-sdk';
|
||||
import Contact from './contact.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';
|
||||
|
||||
@@ -89,8 +89,9 @@ class ContactList extends React.Component {
|
||||
}
|
||||
contactNodes.push(
|
||||
<Contact
|
||||
tinode={this.props.tinode}
|
||||
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}
|
||||
preview={preview}
|
||||
received={deliveryStatus}
|
||||
@@ -105,6 +106,9 @@ class ContactList extends React.Component {
|
||||
isChannel={isChannel}
|
||||
onSelected={this.props.onTopicSelected}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
isVerified={c.trusted && c.trusted.verified}
|
||||
isStaff={c.trusted && c.trusted.staff}
|
||||
isDangerous={c.trusted && c.trusted.danger}
|
||||
item={key}
|
||||
index={contactNodes.length}
|
||||
key={key} />
|
||||
|
||||
+22
-5
@@ -65,6 +65,15 @@ class Contact extends React.Component {
|
||||
const avatar = this.props.avatar ? this.props.avatar : true;
|
||||
const badges = this.props.badges ? this.props.badges.slice() : [];
|
||||
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.showMode) {
|
||||
badges.push({name: this.props.acs.getMode(), key: 'mode'});
|
||||
@@ -79,7 +88,13 @@ class Contact extends React.Component {
|
||||
|
||||
const subtitle = 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, draftyFormatter, this)) :
|
||||
<><i className="material-icons gray">error_outline</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when message is unreadable" /></i>
|
||||
</>
|
||||
) :
|
||||
this.props.comment;
|
||||
|
||||
const icon = deliveryMarker(this.props.received);
|
||||
@@ -90,6 +105,7 @@ class Contact extends React.Component {
|
||||
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={avatar}
|
||||
title={this.props.title}
|
||||
topic={this.props.item} />
|
||||
@@ -101,11 +117,11 @@ class Contact extends React.Component {
|
||||
<div className="text-box">
|
||||
<div><span className="contact-title">{title}</span>
|
||||
{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>
|
||||
{this.props.showMode ?
|
||||
<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>
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
@@ -136,6 +152,7 @@ function draftyFormatter(style, data, values, key) {
|
||||
attr.className = 'highlight preview';
|
||||
break;
|
||||
case 'LN':
|
||||
case 'MN':
|
||||
// Disable links in previews.
|
||||
el = 'span';
|
||||
break;
|
||||
@@ -151,7 +168,7 @@ function draftyFormatter(style, data, values, key) {
|
||||
case 'FM':
|
||||
el = React.Fragment;
|
||||
values = [<i key="fm" className="material-icons">dashboard</i>,
|
||||
formatMessage(messages.drafty_form)].concat(values || []);
|
||||
formatMessage(messages.drafty_form)].concat(' ', values || []);
|
||||
break;
|
||||
case 'RW':
|
||||
el = React.Fragment;
|
||||
@@ -167,7 +184,7 @@ function draftyFormatter(style, data, values, key) {
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
el = React.Fragment;
|
||||
values = [<i key="unkn" className="material-icons">extension</i>];
|
||||
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Context Menu: popup/dropdown menu.
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { REM_SIZE } from '../config.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
@@ -114,6 +112,8 @@ class ContextMenu extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.selfRef = React.createRef();
|
||||
|
||||
const {formatMessage} = props.intl;
|
||||
|
||||
this.handlePageClick = this.handlePageClick.bind(this);
|
||||
@@ -330,7 +330,7 @@ class ContextMenu extends React.Component {
|
||||
}
|
||||
|
||||
handlePageClick(e) {
|
||||
if (ReactDOM.findDOMNode(this).contains(e.target)) {
|
||||
if (this.selfRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -354,7 +354,7 @@ class ContextMenu extends React.Component {
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
|
||||
console.error("Invalid menu item ID", e.currentTarget.dataset.id);
|
||||
} else {
|
||||
this.props.onAction(
|
||||
item.id,
|
||||
@@ -427,9 +427,13 @@ class ContextMenu extends React.Component {
|
||||
return result;
|
||||
}
|
||||
|
||||
replyToMessage(params, errorHandler) {
|
||||
params.pickReply({ seq: params.seq, content: params.content });
|
||||
}
|
||||
|
||||
render() {
|
||||
const menu = [];
|
||||
let count = 0;
|
||||
let menu = [];
|
||||
this.props.items.map((item) => {
|
||||
if (typeof item == 'string') {
|
||||
item = this.MenuItems[item];
|
||||
@@ -461,7 +465,7 @@ class ContextMenu extends React.Component {
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="menu" style={position}>
|
||||
<ul className="menu" style={position} ref={this.selfRef}>
|
||||
{menu}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export default class DocPreview extends React.PureComponent {
|
||||
</div>
|
||||
<SendMessage
|
||||
noInput={true}
|
||||
tinode={this.props.tinode}
|
||||
replyTo={this.props.replyTo}
|
||||
formatter={this.props.formatter}
|
||||
onCancelReply={this.props.onCancelReply}
|
||||
onSendMessage={this.handleSendDoc}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
|
||||
@@ -165,6 +165,7 @@ class GroupManager extends React.Component {
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<ChipInput
|
||||
tinode={this.props.tinode}
|
||||
chips={this.state.members}
|
||||
staticMembers={this.state.staticMembers}
|
||||
prompt="add members"
|
||||
@@ -178,6 +179,7 @@ class GroupManager extends React.Component {
|
||||
</label>
|
||||
</div>
|
||||
<ContactList
|
||||
tinode={this.props.tinode}
|
||||
contacts={this.props.contacts}
|
||||
myUserId={this.props.myUserId}
|
||||
topicSelected={this.state.selectedContacts}
|
||||
@@ -188,12 +190,12 @@ class GroupManager extends React.Component {
|
||||
showUnread={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
<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}>
|
||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||
</button>
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FormattedMessage } from 'react-intl';
|
||||
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
|
||||
|
||||
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 {
|
||||
constructor(props) {
|
||||
@@ -22,8 +22,9 @@ export default class GroupSubs extends React.Component {
|
||||
usersOnline.push(
|
||||
<div className="avatar-box" key={sub.user}>
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,10 @@ export default class ImagePreview extends React.PureComponent {
|
||||
<SendMessage
|
||||
messagePrompt="add_image_caption"
|
||||
acceptBlank={true}
|
||||
tinode={this.props.tinode}
|
||||
replyTo={this.props.replyTo}
|
||||
formatter={this.props.formatter}
|
||||
onCancelReply={this.props.onCancelReply}
|
||||
onSendMessage={this.handleSendImage}
|
||||
onError={this.props.onError} />
|
||||
:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// In-place text editor. Shows text with an icon which toggles it into an input field.
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import VisiblePassword from './visible-password.jsx';
|
||||
|
||||
@@ -8,6 +7,8 @@ export default class InPlaceEdit extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.selfRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
active: props.active,
|
||||
initialValue: props.value || '',
|
||||
@@ -34,7 +35,7 @@ export default class InPlaceEdit extends React.Component {
|
||||
}
|
||||
|
||||
handeTextChange(e) {
|
||||
this.setState({value: e.target.value});
|
||||
this.setState({value: e.target.value || ''});
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
@@ -47,21 +48,24 @@ export default class InPlaceEdit extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleStartEditing() {
|
||||
handleStartEditing() {
|
||||
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) {
|
||||
if (this.props.required && !event.target.checkValidity()) {
|
||||
const value = this.state.value.trim();
|
||||
if (this.props.required && (!event.target.checkValidity() || !value)) {
|
||||
// Empty input
|
||||
this.setState({value: this.props.value, active: false});
|
||||
return;
|
||||
}
|
||||
this.setState({active: false});
|
||||
let value = this.state.value.trim();
|
||||
if ((value || this.props.value) && (value !== this.props.value)) {
|
||||
this.props.onFinished(value);
|
||||
}
|
||||
@@ -75,46 +79,47 @@ export default class InPlaceEdit extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.active) {
|
||||
var fieldType = this.props.type || 'text';
|
||||
} else {
|
||||
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
|
||||
var spanClass = 'in-place-edit' +
|
||||
(this.props.readOnly ? ' disabled' : '');
|
||||
if (!this.state.active) {
|
||||
let spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
|
||||
let spanClass = 'in-place-edit' + (this.props.readOnly ? ' disabled' : '');
|
||||
if (!spanText) {
|
||||
spanText = this.props.placeholder;
|
||||
spanClass += ' placeholder';
|
||||
}
|
||||
if (spanText.length > 20) {
|
||||
// FIXME: this is wrong for RTL languages.
|
||||
spanText = spanText.substring(0, 19) + '...';
|
||||
if (!this.props.multiline || this.props.multiline == 1) {
|
||||
spanClass += ' short';
|
||||
}
|
||||
|
||||
return (<span className={spanClass} onClick={this.handleStartEditing}>
|
||||
<span>{spanText}</span>
|
||||
</span>);
|
||||
}
|
||||
return (
|
||||
this.state.active ?
|
||||
(fieldType == 'password' ?
|
||||
<VisiblePassword
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
autoFocus={true}
|
||||
onFinished={this.handlePasswordFinished}/>
|
||||
:
|
||||
<input type={fieldType}
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
autoFocus
|
||||
onChange={this.handeTextChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleEditingFinished} />
|
||||
)
|
||||
:
|
||||
<span className={spanClass} onClick={this.handleStartEditing}>
|
||||
<span className="content">{spanText}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
let element;
|
||||
const attr = {};
|
||||
if (this.props.type == 'password') {
|
||||
element = VisiblePassword;
|
||||
attr.onFinished = this.handlePasswordFinished;
|
||||
} else {
|
||||
if (this.props.multiline > 1) {
|
||||
element = 'textarea';
|
||||
attr.rows = this.props.multiline;
|
||||
attr.className = 'inplace-edit';
|
||||
} else {
|
||||
element = 'input';
|
||||
attr.type = this.props.type || 'text';
|
||||
}
|
||||
attr.value = this.state.value;
|
||||
attr.onChange = this.handeTextChange;
|
||||
attr.onKeyDown = this.handleKeyDown;
|
||||
attr.onBlur = this.handleEditingFinished;
|
||||
}
|
||||
attr.placeholder = this.props.placeholder;
|
||||
attr.required = this.props.required ? 'required' : '';
|
||||
attr.autoComplete = this.props.autoComplete;
|
||||
attr.autoFocus = true;
|
||||
attr.ref = this.selfRef;
|
||||
|
||||
return React.createElement(element, attr, null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,15 +2,15 @@ import React from 'react';
|
||||
|
||||
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 {
|
||||
render() {
|
||||
let avatar;
|
||||
if (this.props.avatar === true) {
|
||||
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
||||
const iconColor = (isGroup ? 'light-color' : 'dark-color')
|
||||
+ (Math.abs(stringHash(this.props.topic)) % 16);
|
||||
const iconColor = idToColorClass(this.props.topic, isGroup);
|
||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||
const letter = this.props.title.trim().charAt(0);
|
||||
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>;
|
||||
}
|
||||
} else if (this.props.avatar) {
|
||||
const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
|
||||
// If avatar image is invalid, show a placeholder.
|
||||
avatar = <img className="avatar" alt="avatar" src={this.props.avatar}
|
||||
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />;
|
||||
avatar = <img className="avatar" alt="avatar" src={url}
|
||||
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
|
||||
} else {
|
||||
avatar = null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
|
||||
export default class LoadSpinner extends React.PureComponent {
|
||||
render() {
|
||||
const className = 'load-spinner-box' + (this.props.large ? ' large' : '')
|
||||
+ (this.props.clear ? ' clear' : '') + (this.props.centered ? ' centered' : '');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -5,34 +5,36 @@ import AvatarUpload from './avatar-upload.jsx';
|
||||
import CheckBox from './checkbox.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 {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.fullName = React.createRef();
|
||||
|
||||
this.state = {
|
||||
fn: '', // full/formatted name
|
||||
fullName: '', // full/formatted name
|
||||
private: '',
|
||||
description: '',
|
||||
imageDataUrl: null,
|
||||
tags: [],
|
||||
isChannel: false
|
||||
};
|
||||
|
||||
this.handleFnChange = this.handleFnChange.bind(this);
|
||||
this.handlePrivateChange = this.handlePrivateChange.bind(this);
|
||||
this.handleFieldEdit = this.handleFieldEdit.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleTagsChanged = this.handleTagsChanged.bind(this);
|
||||
this.handleChannelToggle = this.handleChannelToggle.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleFnChange(e) {
|
||||
this.setState({fn: e.target.value});
|
||||
componentDidMount() {
|
||||
// this.fullName.current.focus();
|
||||
}
|
||||
|
||||
handlePrivateChange(e) {
|
||||
this.setState({private: e.target.value});
|
||||
handleFieldEdit(name, e) {
|
||||
this.setState({[name]: e.target.value || ''});
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
@@ -50,10 +52,11 @@ export default class NewTopicGroup extends React.PureComponent {
|
||||
handleSubmit(e) {
|
||||
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 description = this.state.description.trim().substring(0, MAX_TOPIC_DESCRIPTION_LENGTH);
|
||||
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 (
|
||||
<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">
|
||||
<FormattedMessage id="label_topic_name" defaultMessage="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"
|
||||
description="Prompt for entering topic name">{
|
||||
(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>
|
||||
<br />
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="small" htmlFor="new-topic-priv">
|
||||
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
||||
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"
|
||||
description="Placeholder for editing 'private'">{
|
||||
(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>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
onError={this.props.onError}
|
||||
onImageChanged={this.handleImageChanged} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<CheckBox checked={this.state.isChannel} onChange={this.handleChannelToggle}/>
|
||||
<CheckBox checked={this.state.isChannel} tabIndex={3} onChange={this.handleChannelToggle} />
|
||||
<label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt"
|
||||
defaultMessage="This is a channel"
|
||||
description="Checkbox label when creating a channel" /></label>
|
||||
</div>
|
||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (search & discovery)"
|
||||
description="Section title for TagManager">{
|
||||
(title) => <TagManager
|
||||
tinode={this.props.tinode}
|
||||
tags={this.state.tags}
|
||||
activated={true}
|
||||
onTagsChanged={this.handleTagsChanged}
|
||||
tinode={this.props.tinode}
|
||||
tabIndex={4}
|
||||
title={title} />
|
||||
}</FormattedMessage>
|
||||
<div className="dialog-buttons">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import CheckBox from './checkbox.jsx';
|
||||
import Contact from './contact.jsx';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
// Translatable permission names.
|
||||
const messages = defineMessages({
|
||||
@@ -67,7 +67,7 @@ class PermissionsEditor extends React.Component {
|
||||
|
||||
handleChange(val) {
|
||||
let mode = this.state.mode;
|
||||
let idx = mode.indexOf(val);
|
||||
const idx = mode.indexOf(val);
|
||||
if (idx == -1) {
|
||||
mode += val;
|
||||
} else {
|
||||
@@ -78,8 +78,8 @@ class PermissionsEditor extends React.Component {
|
||||
|
||||
handleSubmit() {
|
||||
// Normalize string, otherwise cannot check if mode has changed.
|
||||
var mode = (this.state.mode || 'N').split('').sort().join('');
|
||||
var before = (this.props.mode || 'N').split('').sort().join('')
|
||||
const mode = (this.state.mode || 'N').split('').sort().join('');
|
||||
const before = (this.props.mode || 'N').split('').sort().join('')
|
||||
if (mode !== before) {
|
||||
this.props.onSubmit(mode);
|
||||
} else {
|
||||
@@ -132,10 +132,12 @@ class PermissionsEditor extends React.Component {
|
||||
return (
|
||||
<div className="panel-form-column">
|
||||
{this.props.userTitle ?
|
||||
<ul className="contact-box"><Contact
|
||||
<ul className="contact-box small"><Contact
|
||||
tinode={this.props.tinode}
|
||||
item={this.props.item}
|
||||
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"
|
||||
defaultMessage="Permissions" description="Section title"/></label>
|
||||
<table className="permission-editor">
|
||||
|
||||
@@ -50,6 +50,8 @@ class SendMessage extends React.PureComponent {
|
||||
this.handleSend = this.handleSend.bind(this);
|
||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
||||
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -141,6 +143,15 @@ class SendMessage extends React.PureComponent {
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
handleQuoteClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.props.replyTo && this.props.onQuoteClick) {
|
||||
const replyToSeq = this.props.replyTo.seq;
|
||||
this.props.onQuoteClick(replyToSeq);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const prompt = this.props.disabled ?
|
||||
@@ -148,7 +159,24 @@ class SendMessage extends React.PureComponent {
|
||||
(this.props.messagePrompt ?
|
||||
formatMessage(messages[this.props.messagePrompt]) :
|
||||
formatMessage(messages.type_new_message));
|
||||
|
||||
let quote = null;
|
||||
if (this.props.replyTo) {
|
||||
quote = Drafty.format(this.props.replyTo.content, this.props.formatter, this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="send-message-wrapper">
|
||||
{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>
|
||||
{quote}
|
||||
</div>
|
||||
:
|
||||
null}
|
||||
|
||||
<div id="send-message-panel">
|
||||
{!this.props.disabled ?
|
||||
<>
|
||||
@@ -182,6 +210,7 @@ class SendMessage extends React.PureComponent {
|
||||
<div id="writing-disabled">{prompt}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+32
-18
@@ -2,30 +2,44 @@ import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import ButtonBack from './button-back.jsx';
|
||||
import ContactBadges from './contact-badges.jsx';
|
||||
import MenuContacts from './menu-contacts.jsx';
|
||||
import MenuStart from './menu-start.jsx';
|
||||
|
||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||
|
||||
export default class SideNavbar extends React.PureComponent {
|
||||
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 (
|
||||
<div id="side-caption-panel" className="caption-panel">
|
||||
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
|
||||
{this.props.avatar ?
|
||||
<div id="self-avatar" className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={this.props.avatar}
|
||||
topic={this.props.myUserId}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
:
|
||||
null}
|
||||
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
|
||||
{this.props.state === 'login' ?
|
||||
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
||||
this.props.state === 'contacts' ?
|
||||
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
||||
null}
|
||||
</div>
|
||||
<div id="side-caption-panel" className="caption-panel">
|
||||
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
|
||||
{avatar ?
|
||||
<div id="self-avatar" className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={avatar}
|
||||
topic={this.props.myUserId}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
:
|
||||
null}
|
||||
<div id="sidepanel-title" className="panel-title">{this.props.title}<ContactBadges badges={icon_badges}/></div>
|
||||
{this.props.state === 'login' ?
|
||||
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
||||
this.props.state === 'contacts' ?
|
||||
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
||||
null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export default class TagManager extends React.Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tags: this.props.tags,
|
||||
tags: this.props.tags || [],
|
||||
tagInput: '',
|
||||
activated: this.props.activated
|
||||
};
|
||||
@@ -25,8 +25,9 @@ export default class TagManager extends React.Component {
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
|
||||
return {tags: nextProps.tags};
|
||||
const tags = nextProps.tags || [];
|
||||
if (!arrayEqual(tags, prevState.tags) && !prevState.activated) {
|
||||
return {tags: tags};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -75,11 +76,11 @@ export default class TagManager extends React.Component {
|
||||
handleSubmit() {
|
||||
// Add unprocessed input to tags, submit the list.
|
||||
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() {
|
||||
this.setState({activated: false, tagInput: '', tags: this.props.tags});
|
||||
this.setState({activated: false, tagInput: '', tags: this.props.tags || []});
|
||||
if (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"
|
||||
description="Tag editor prompt when no tags are found.">{
|
||||
(add_tags_prompt) => <ChipInput
|
||||
tinode={this.props.tinode}
|
||||
chips={tags}
|
||||
avatarDisabled={true}
|
||||
prompt={add_tags_prompt}
|
||||
tabIndex={this.props.tabIndex}
|
||||
onEnter={this.handleAddTag}
|
||||
onFocusLost={this.handleAddTag}
|
||||
onCancel={this.handleCancel}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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 = (mime, blob, width, height) => {
|
||||
if (blob.size > MAX_AVATAR_BYTES) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
|
||||
this.setState({uploading: true});
|
||||
uploader.upload(blob)
|
||||
.then((url) => {
|
||||
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url));
|
||||
})
|
||||
.catch((err) => {
|
||||
this.props.onError(err, 'err');
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({uploading: false});
|
||||
});
|
||||
} else {
|
||||
this.setState({uploading: true});
|
||||
// Convert blob to base64-encoded bits.
|
||||
blobToBase64(blob, (unused, base64bits) => {
|
||||
const du = makeImageUrl({data: base64bits, type: mime});
|
||||
this.setState({source: du});
|
||||
this.props.onUpdateTopicDesc(this.props.topic, theCard(null, du));
|
||||
this.setState({uploading: false});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
|
||||
// Avatar is not square or too large even after cropping. Shrink it and make square.
|
||||
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
|
||||
readyToUpload,
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
});
|
||||
} else {
|
||||
readyToUpload(mime, blob, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
handleAvatarCropCancel(img) {
|
||||
this.setState({newAvatar: null, newAvatarMime: null});
|
||||
}
|
||||
|
||||
handleTagsUpdated(tags) {
|
||||
// Check if tags have actually changed.
|
||||
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||
return;
|
||||
}
|
||||
this.props.onUpdateTags(tags);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.newAvatar) {
|
||||
return (
|
||||
<AvatarCrop
|
||||
avatar={this.state.newAvatar}
|
||||
mime={this.state.newAvatarMime}
|
||||
onSubmit={this.handleAvatarCropped}
|
||||
onCancel={this.handleAvatarCropCancel}
|
||||
onError={this.props.onError} />
|
||||
);
|
||||
}
|
||||
|
||||
const editable = this.state.isMe || this.state.owner;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel-form-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
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
// 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> {
|
||||
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> {formatMessage(messages.topic_delete)}
|
||||
</a>
|
||||
:
|
||||
<a href="#" className="danger flat-button" onClick={this.handleLeave}>
|
||||
<i className="material-icons">exit_to_app</i> {formatMessage(messages.leave_chat)}
|
||||
</a>
|
||||
}
|
||||
{!this.props.groupTopic ?
|
||||
<a href="#" className="danger flat-button" onClick={this.handleBlock}>
|
||||
<i className="material-icons">block</i> {formatMessage(messages.block_contact)}
|
||||
</a>
|
||||
:
|
||||
null
|
||||
}
|
||||
{!this.props.owner ?
|
||||
<a href="#" className="danger flat-button" onClick={this.handleReport}>
|
||||
<i className="material-icons">report</i> {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>
|
||||
<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="clickable"
|
||||
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('auth')}}>{this.props.auth}</tt>
|
||||
</div>
|
||||
<div>Anon: <tt className="clickable"
|
||||
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('anon')}}>{this.props.anon}</tt>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<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)}:
|
||||
<tt className="clickable"
|
||||
onClick={(e) => {e.preventDefault(); this.props.onLaunchPermissionsEditor('given')}}>
|
||||
{this.props.modeGiven2}
|
||||
</tt>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(TopicSecurity);
|
||||
Reference in New Issue
Block a user