Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.
Backup checkpoint before reconciling with origin/dev's parallel rewrite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
202 lines
7.9 KiB
React
202 lines
7.9 KiB
React
import React from 'react';
|
|
import { FormattedMessage } from 'react-intl';
|
|
|
|
import AvatarCrop from './avatar-crop.jsx';
|
|
import AvatarUpload from './avatar-upload.jsx';
|
|
import CheckBox from './checkbox.jsx';
|
|
import TagManager from './tag-manager.jsx';
|
|
|
|
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH,
|
|
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
|
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
|
import { createChatUploader } from '../lib/im-api.js';
|
|
import { truncateString } from '../lib/strformat.js';
|
|
|
|
export default class NewTopicGroup extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.fullName = React.createRef();
|
|
|
|
this.state = {
|
|
fullName: '', // full/formatted name
|
|
private: '',
|
|
description: '',
|
|
imageUrl: null,
|
|
tags: [],
|
|
isChannel: false,
|
|
newAvatar: null,
|
|
newAvatarMime: null
|
|
};
|
|
|
|
this.handleFieldEdit = this.handleFieldEdit.bind(this);
|
|
this.handleImageChanged = this.handleImageChanged.bind(this);
|
|
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
|
|
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
|
|
this.uploadAvatar = this.uploadAvatar.bind(this);
|
|
this.handleTagsChanged = this.handleTagsChanged.bind(this);
|
|
this.handleChannelToggle = this.handleChannelToggle.bind(this);
|
|
this.handleSubmit = this.handleSubmit.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
// this.fullName.current.focus();
|
|
}
|
|
|
|
handleFieldEdit(name, e) {
|
|
this.setState({[name]: e.target.value || ''});
|
|
}
|
|
|
|
handleImageChanged(mime, img) {
|
|
this.setState({newAvatar: img, newAvatarMime: mime});
|
|
}
|
|
|
|
handleAvatarCropped(mime, blob, width, height) {
|
|
const url = blob ? URL.createObjectURL(blob) : null;
|
|
this.setState({imageUrl: url, newAvatar: null, newAvatarMime: null});
|
|
if (blob) {
|
|
this.uploadAvatar(mime, blob, width, height);
|
|
}
|
|
}
|
|
|
|
handleAvatarCropCancel() {
|
|
this.setState({newAvatar: null, newAvatarMime: null});
|
|
}
|
|
|
|
// 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} = image;
|
|
if (blob.size > MAX_AVATAR_BYTES) {
|
|
// Too large to send inband - uploading out of band and sending as a link.
|
|
// Same wrong-header bug messages-view.jsx's chat uploads had -- createChatUploader() is the
|
|
// fixed-headers drop-in (see im-api.js for the full explanation).
|
|
const uploader = createChatUploader(this.props.tinode);
|
|
uploader.upload(blob)
|
|
.then(url => this.setState({imageUrl: url}))
|
|
.catch(err => this.props.onError(err.message, 'err'));
|
|
} else {
|
|
// Convert blob to base64-encoded bits.
|
|
blobToBase64(blob)
|
|
.then(b64 => this.setState({imageUrl: makeImageUrl({data: b64.bits, type: mime})}));
|
|
}
|
|
};
|
|
|
|
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.message, 'err'));
|
|
} else {
|
|
readyToUpload({mime: mime, blob: blob, width: width, height: height});
|
|
}
|
|
}
|
|
|
|
handleTagsChanged(tags) {
|
|
this.setState({tags: tags});
|
|
}
|
|
|
|
handleChannelToggle() {
|
|
this.setState({isChannel: !this.state.isChannel});
|
|
}
|
|
|
|
handleSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const fn = truncateString(this.state.fullName.trim(), MAX_TITLE_LENGTH);
|
|
const comment = truncateString(this.state.private.trim(), MAX_TITLE_LENGTH);
|
|
const description = truncateString(this.state.description.trim(), MAX_TOPIC_DESCRIPTION_LENGTH);
|
|
if (fn) {
|
|
this.props.onSubmit(fn, description, this.state.imageUrl, comment, this.state.tags, this.state.isChannel);
|
|
}
|
|
}
|
|
|
|
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} />
|
|
);
|
|
}
|
|
|
|
let submitClasses = 'primary';
|
|
if (this.props.disabled) {
|
|
submitClasses += ' disabled';
|
|
}
|
|
return (
|
|
<form className="panel-form" onSubmit={this.handleSubmit}>
|
|
<div className="panel-form-column">
|
|
<center>
|
|
<AvatarUpload
|
|
tinode={this.props.tinode}
|
|
avatar={this.state.imageUrl}
|
|
onError={this.props.onError}
|
|
onImageUpdated={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" />
|
|
</label>
|
|
<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}
|
|
ref={this.fullName} value={this.state.fullName} onChange={this.handleFieldEdit.bind(this, 'fullName')}
|
|
autoFocus required tabIndex={0} />
|
|
}</FormattedMessage>
|
|
</div>
|
|
<div className="group">
|
|
<label className="small" htmlFor="new-topic-priv">
|
|
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
|
description="Label for editing 'private'" />
|
|
</label>
|
|
<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.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 description of user or topic" />
|
|
</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>
|
|
</div>
|
|
<div className="panel-form-row">
|
|
<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 (search & discovery)"
|
|
description="Section title for TagManager">{
|
|
(title) => <TagManager
|
|
tinode={this.props.tinode}
|
|
tags={this.state.tags}
|
|
activated={true}
|
|
onTagsChanged={this.handleTagsChanged}
|
|
tabIndex={4}
|
|
title={title} />
|
|
}</FormattedMessage>
|
|
<div className="dialog-buttons">
|
|
<button className={submitClasses}>
|
|
<FormattedMessage id="button_create" defaultMessage="Create"
|
|
description="Button [Create]" />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
};
|