From 365e3284c2a8c55e25c0125d3a04ed1fd48a2c6f Mon Sep 17 00:00:00 2001 From: Code Migration Date: Sun, 18 Oct 2026 13:59:32 +0800 Subject: [PATCH] chore: seed migration objects batch 2 --- src/version.js | 2 +- src/views/acc-general-view.jsx | 189 - src/views/acc-security-view.jsx | 10 +- src/views/account-settings-view.jsx | 85 + src/views/contacts-view.jsx | 1 + src/views/create-account-view.jsx | 1 + src/views/edit-account-view.jsx | 66 - src/views/info-view.jsx | 693 ++-- src/views/logo-view.jsx | 2 +- src/views/messages-view.jsx | 435 ++- src/views/new-topic-view.jsx | 21 +- src/views/sidepanel-view.jsx | 47 +- src/views/tinode-web.jsx | 265 +- src/views/validation-view.jsx | 6 +- src/widgets/avatar-crop.jsx | 68 + src/widgets/avatar-upload.jsx | 42 +- src/widgets/badge-list.jsx | 43 + src/widgets/chat-message.jsx | 164 +- src/widgets/checkbox.jsx | 6 +- src/widgets/chip-input.jsx | 9 +- src/widgets/chip.jsx | 1 + src/widgets/contact-badges.jsx | 32 +- src/widgets/contact-list.jsx | 13 +- src/widgets/contact.jsx | 103 +- src/widgets/context-menu.jsx | 38 +- src/widgets/cropper.jsx | 279 ++ src/widgets/doc-preview.jsx | 9 +- src/widgets/forward-dialog.jsx | 88 + src/widgets/group-manager.jsx | 8 +- src/widgets/group-subs.jsx | 5 +- src/widgets/image-preview.jsx | 19 +- src/widgets/in-place-edit.jsx | 89 +- src/widgets/lazy-image.jsx | 42 + src/widgets/letter-tile.jsx | 11 +- src/widgets/load-spinner.jsx | 4 +- src/widgets/more-button.jsx | 29 - src/widgets/new-topic-group.jsx | 62 +- src/widgets/permissions-editor.jsx | 14 +- src/widgets/search-contacts.jsx | 14 +- src/widgets/send-message.jsx | 108 +- src/widgets/side-navbar.jsx | 50 +- src/widgets/tag-manager.jsx | 13 +- src/widgets/topic-common.jsx | 180 + src/widgets/topic-desc-edit.jsx | 285 ++ src/widgets/topic-security.jsx | 266 ++ umd/index.dev.js | 5540 ++++++++++++++++++--------- umd/index.dev.js.map | 2 +- 47 files changed, 6331 insertions(+), 3128 deletions(-) delete mode 100644 src/views/acc-general-view.jsx create mode 100644 src/views/account-settings-view.jsx delete mode 100644 src/views/edit-account-view.jsx create mode 100644 src/widgets/avatar-crop.jsx create mode 100644 src/widgets/badge-list.jsx create mode 100644 src/widgets/cropper.jsx create mode 100644 src/widgets/forward-dialog.jsx create mode 100644 src/widgets/lazy-image.jsx delete mode 100644 src/widgets/more-button.jsx create mode 100644 src/widgets/topic-common.jsx create mode 100644 src/widgets/topic-desc-edit.jsx create mode 100644 src/widgets/topic-security.jsx diff --git a/src/version.js b/src/version.js index 560111a9..abbf8a40 100644 --- a/src/version.js +++ b/src/version.js @@ -1,3 +1,3 @@ // This is a generated file. Don't edit. -export const PACKAGE_VERSION = "0.17.10"; +export const PACKAGE_VERSION = "0.18.0-beta1"; diff --git a/src/views/acc-general-view.jsx b/src/views/acc-general-view.jsx deleted file mode 100644 index dbacbd12..00000000 --- a/src/views/acc-general-view.jsx +++ /dev/null @@ -1,189 +0,0 @@ -// Edit account parameters. -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -import Tinode from 'tinode-sdk'; - -import AvatarUpload from '../widgets/avatar-upload.jsx'; -import InPlaceEdit from '../widgets/in-place-edit.jsx'; -import TagManager from '../widgets/tag-manager.jsx'; - -import { MAX_TITLE_LENGTH } from '../config.js'; -import { makeImageDataUrl } from '../lib/blob-helpers.js'; -import { arrayEqual, asEmail, asPhone, theCard } from '../lib/utils.js'; - -export default class AccGeneralView extends React.Component { - constructor(props) { - super(props); - - const me = this.props.tinode.getMeTopic(); - this.state = { - fullName: me.public ? me.public.fn : undefined, - avatar: makeImageDataUrl(me.public ? me.public.photo : null), - tags: me.tags(), - credentials: me.getCredentials() || [], - addCredActive: false, - addCredInvalid: false, - newCred: '', - previousOnTags: me.onTagsUpdated - }; - - this.tnNewTags = this.tnNewTags.bind(this); - this.tnCredsUpdated = this.tnCredsUpdated.bind(this); - this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this); - this.handleImageChanged = this.handleImageChanged.bind(this); - this.handleCredChange = this.handleCredChange.bind(this); - this.handleCredKeyDown = this.handleCredKeyDown.bind(this); - this.handleCredEntered = this.handleCredEntered.bind(this); - this.handleTagsUpdated = this.handleTagsUpdated.bind(this); - } - - componentDidMount() { - const me = this.props.tinode.getMeTopic(); - me.onCredsUpdated = this.tnCredsUpdated; - me.onTagsUpdated = this.tnNewTags; - } - - componentWillUnmount() { - const me = this.props.tinode.getMeTopic(); - me.onTagsUpdated = this.state.previousOnTags; - me.onCredsUpdated = undefined; - } - - tnNewTags(tags) { - this.setState({tags: tags}); - } - - tnCredsUpdated(creds) { - this.setState({credentials: creds || []}); - } - - handleFullNameUpdate(fn) { - fn = fn.trim().substring(0, MAX_TITLE_LENGTH); - if (fn) { - this.setState({fullName: fn}); - this.props.onUpdateAccount(undefined, theCard(fn, null)); - } - } - - handleImageChanged(img) { - this.setState({avatar: img}); - this.props.onUpdateAccount(undefined, theCard(null, img || Tinode.DEL_CHAR)); - } - - handleCredChange(e) { - this.setState({newCred: e.target.value, addCredInvalid: false}); - } - - handleCredKeyDown(e) { - if (e.keyCode === 27) { - // Escape pressed - this.setState({newCred: '', addCredActive: false}); - } else if (e.keyCode === 13) { - // Enter pressed - this.handleCredEntered(e); - } - } - - handleCredEntered(e) { - let value = this.state.newCred.trim(); - if (!value) { - this.setState({addCredActive: false, addCredInvalid: false}); - return; - } - - let val = asPhone(value); - let method; - if (val) { - method = 'tel'; - } else { - val = asEmail(value); - if (val) { - method = 'email'; - } - } - if (method) { - this.props.onCredAdd(method, val); - this.setState({addCredActive: false, newCred: ''}); - } else { - this.setState({addCredInvalid: true}); - } - } - - handleTagsUpdated(tags) { - // Check if tags have actually changed. - if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) { - return; - } - this.props.onUpdateTags(tags); - } - - render() { - const credentials = []; - this.state.credentials.map((cred) => { - credentials.push(
{cred.meth}: {cred.val} - {!cred.done ? - {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}> - - - : null} {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}>delete_outline
); - }); - - return ( -
-
-
- -
{ - (full_name_placeholder) => - }
-
- -
-
- { - (title_tag_manager) => - } -
-
- -
- {credentials} - {this.state.addCredActive ? - - : null} - -
-
-
- ); - } -}; diff --git a/src/views/acc-security-view.jsx b/src/views/acc-security-view.jsx index a2fcd5cb..9574dcd3 100644 --- a/src/views/acc-security-view.jsx +++ b/src/views/acc-security-view.jsx @@ -12,7 +12,7 @@ const messages = defineMessages({ description: 'Title for delete account warning' }, delete_account_warning: { - id: 'delete_account_arning', + id: 'delete_account_warning', defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.', description: 'Warning message when deleting an account' } @@ -46,7 +46,7 @@ class AccSecurityView extends React.Component { handlePasswordUpdate(pwd) { this.setState({password: pwd}); - this.props.onUpdateAccount(pwd); + this.props.onUpdatePassword(pwd); } handleLaunchPermissionsEditor(which) { @@ -63,7 +63,7 @@ class AccSecurityView extends React.Component { handlePermissionsChanged(perm) { let defacs = {}; defacs[this.state.showPermissionEditorFor] = perm; - this.props.onUpdateAccount(undefined, undefined, defacs); + this.props.onUpdateAccountDesc('me', undefined, undefined, defacs); let newState = {showPermissionEditorFor: undefined}; newState[this.state.showPermissionEditorFor] = perm; @@ -85,7 +85,7 @@ class AccSecurityView extends React.Component { render() { return ( - {this.state.showPermissionEditorFor ? + <>{this.state.showPermissionEditorFor ? : null }
- } + } ); } }; diff --git a/src/views/account-settings-view.jsx b/src/views/account-settings-view.jsx new file mode 100644 index 00000000..c4e7f2b6 --- /dev/null +++ b/src/views/account-settings-view.jsx @@ -0,0 +1,85 @@ +// Edit account parameters. +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import AvatarUpload from '../widgets/avatar-upload.jsx'; +import BadgeList from '../widgets/badge-list.jsx'; + +import { makeImageUrl } from '../lib/blob-helpers.js'; +import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js'; + +function _clip(str, length) { + return str && str.substring(0, length); +} + +export default class AccountSettingsView extends React.Component { + constructor(props) { + super(props); + + const me = this.props.tinode.getMeTopic(); + this.state = { + fullName: _clip(me.public ? me.public.fn : undefined, MAX_TITLE_LENGTH), + description: _clip(me.public ? me.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH), + avatar: makeImageUrl(me.public ? me.public.photo : null) + }; + } + + render() { + return ( +
+
+ {e.preventDefault(); this.props.onNavigate('general');}}> + edit  + + +
+ +
+
+ +
{this.state.fullName}
+
+
+   + {this.props.myUserId} +
+
+ +
+ {this.state.description ? +
+ +
{this.state.description}
+
: null} +
+
+
+ {e.preventDefault(); this.props.onNavigate('notif');}}> + notifications  + + {e.preventDefault(); this.props.onNavigate('security');}}> + security  + + {e.preventDefault(); this.props.onNavigate('support');}}> + contact_support  + +
+
+ ); + } +}; diff --git a/src/views/contacts-view.jsx b/src/views/contacts-view.jsx index 4179c46a..bec11252 100644 --- a/src/views/contacts-view.jsx +++ b/src/views/contacts-view.jsx @@ -89,6 +89,7 @@ export default class ContactsView extends React.Component { defaultMessage="You have no chats
¯∖_(ツ)_/¯" description="HTML message shown in ContactList when no contacts are found">{ (no_contacts) =>
diff --git a/src/views/edit-account-view.jsx b/src/views/edit-account-view.jsx deleted file mode 100644 index 413375b6..00000000 --- a/src/views/edit-account-view.jsx +++ /dev/null @@ -1,66 +0,0 @@ -// Edit account parameters. -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -import AvatarUpload from '../widgets/avatar-upload.jsx'; - -import { makeImageDataUrl } from '../lib/blob-helpers.js'; - -export default class EditAccountView extends React.Component { - constructor(props) { - super(props); - - const me = this.props.tinode.getMeTopic(); - this.state = { - fullName: me.public ? me.public.fn : undefined, - avatar: makeImageDataUrl(me.public ? me.public.photo : null) - }; - } - - render() { - return ( -
-
-
- -
{this.state.fullName}
-
-   - {this.props.myUserId} -
-
- -
-
- {e.preventDefault(); this.props.onBasicNavigate('general');}}> - edit  - - -
-
-
- {e.preventDefault(); this.props.onBasicNavigate('notif');}}> - notifications  - - {e.preventDefault(); this.props.onBasicNavigate('security');}}> - security  - - {e.preventDefault(); this.props.onBasicNavigate('support');}}> - contact_support  - -
-
- ); - } -}; diff --git a/src/views/info-view.jsx b/src/views/info-view.jsx index dbc00a10..1a90eece 100644 --- a/src/views/info-view.jsx +++ b/src/views/info-view.jsx @@ -5,104 +5,83 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import Tinode from 'tinode-sdk'; import AvatarUpload from '../widgets/avatar-upload.jsx'; +import BadgeList from '../widgets/badge-list.jsx'; import CheckBox from '../widgets/checkbox.jsx'; import ContactList from '../widgets/contact-list.jsx'; import ErrorPanel from '../widgets/error-panel.jsx'; import GroupManager from '../widgets/group-manager.jsx'; -import InPlaceEdit from '../widgets/in-place-edit.jsx'; import MenuCancel from '../widgets/menu-cancel.jsx'; -import MoreButton from '../widgets/more-button.jsx'; import PermissionsEditor from '../widgets/permissions-editor.jsx'; -import TagManager from '../widgets/tag-manager.jsx'; +import TopicCommon from '../widgets/topic-common.jsx'; +import TopicSecurity from '../widgets/topic-security.jsx'; -import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js'; +import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js'; -import { makeImageDataUrl } from '../lib/blob-helpers.js'; +import { makeImageUrl } from '../lib/blob-helpers.js'; import { arrayEqual, theCard } from '../lib/utils.js'; const messages = defineMessages({ - requested: { + info: { + id: 'panel_title_info', + description: 'Title for InfoView', + defaultMessage: 'Info' + }, + general: { + id: 'panel_title_general', + description: 'Title for TopicCommon.', + defaultMessage: 'General' + }, + security: { + id: 'panel_title_security', + description: 'Title for TopicSecirity and AccSecurity.', + defaultMessage: 'Security' + }, + members: { + id: 'panel_title_members', + description: 'Title for managing group members view.', + defaultMessage: 'Members' + }, + crop: { + id: 'panel_title_crop', + description: 'Title for AvatarCropView.', + defaultMessage: 'Drag to Adjust' + }, + perm_want: { id: 'requested_permissions', defaultMessage: 'Requested', description: 'Title for permissions' }, - granted: { + perm_given: { id: 'granted_permissions', defaultMessage: 'Granted', description: 'Title for permissions' }, + perm_auth: { + id: 'permissions_authenticated', + defaultMessage: 'Authenticated', + description: 'Title for editing default authenticated permissions' + }, + perm_anon: { + id: 'permissions_anonymous', + defaultMessage: 'Anonymous', + description: 'Title for editing default anonymous permissions' + }, + perm_user: { + id: 'permissions_user', + defaultMessage: 'User\'s Permissions', + description: 'Title for editing user\'s permissions' + }, edit_permissions: { id: 'menu_item_edit_permissions', defaultMessage: 'Edit permissions', description: 'Menu item [Edit permissions]' }, - other_user: { - id: 'label_other_user', - defaultMessage: 'Other', - description: 'Label for the other user when the user is unnamed' - }, - clear_messages: { - id: 'action_clear_messages', - defaultMessage: 'Clear Messages', - description: 'Flat button [Clear Messages] (soft-delete messages)' - }, - clear_messages_warning: { - id: 'clear_messages_warning', - defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.', - description: 'Alert dialog warning when deleting all messages.' - }, - delete_messages: { - id: 'action_delete_messages', - defaultMessage: 'Clear Messages for All', - description: 'Flat button [Clear for All] (hard-delete all messages)' - }, - delete_messages_warning: { - id: 'delete_messages_warning', - defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.', - description: 'Alert dialog warning when hard-deleting all messages.' - }, - topic_delete: { - id: 'topic_delete', - defaultMessage: 'Delete Conversation', - description: 'Alert title when deleting the topic.' - }, - topic_delete_warning: { - id: 'topic_delete_warning', - defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.', - description: 'Alert warning when deleting entire topic' - }, - leave_chat: { - id: 'action_leave_chat', - defaultMessage: 'Leave Conversation', - description: 'Flat button [Leave Conversation]' - }, - leave_chat_warning: { - id: 'leave_chat_warning', - defaultMessage: 'Are you sure you want to leave this conversation?', - description: 'Alert dialog warning when unsubscribing from a chat.' - }, - block_contact: { - id: 'action_block_contact', - defaultMessage: "Block Contact", - description: "Flat button [Block Contact]" - }, - block_contact_warning: { - id: 'block_contact_warning', - defaultMessage: 'Are you sure you want to block this contact?', - description: 'Alert dialog warning when blocking a contact.' - }, - report_chat: { - id: 'action_report_chat', - defaultMessage: 'Report Conversation', - description: 'Flat button [Report Group]' - }, - report_chat_warning: { - id: 'report_chat_warning', - defaultMessage: 'Are you sure you want to block and report this conversation?', - description: 'Alert dialog warning when reporting a conversation for abuse' - }, }); +function _clip(str, length) { + return str && str.substring(0, length); +} + class InfoView extends React.Component { constructor(props) { super(props); @@ -118,6 +97,7 @@ class InfoView extends React.Component { groupTopic: undefined, channel: undefined, fullName: undefined, + description: undefined, avatar: null, private: null, selectedContact: null, @@ -129,39 +109,26 @@ class InfoView extends React.Component { auth: null, anon: null, contactList: [], - tags: [], - showMemberPanel: false, - showPermissionEditorFor: undefined, - moreInfoExpanded: false, + trustedBadges: [], previousMetaDesc: undefined, previousSubsUpdated: undefined, - previousTagsUpdated: undefined }; this.resetSubs = this.resetSubs.bind(this); this.resetDesc = this.resetDesc.bind(this); + this.resetTags = this.resetTags.bind(this); this.onMetaDesc = this.onMetaDesc.bind(this); this.onSubsUpdated = this.onSubsUpdated.bind(this); - this.onTagsUpdated = this.onTagsUpdated.bind(this); - this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this); - this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this); this.handleImageChanged = this.handleImageChanged.bind(this); this.handleMuted = this.handleMuted.bind(this); + this.handleUnarchive = this.handleUnarchive.bind(this); this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this); this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this); - this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this); this.handleShowAddMembers = this.handleShowAddMembers.bind(this); - this.handleHideAddMembers = this.handleHideAddMembers.bind(this); this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this); - this.handleDeleteTopic = this.handleDeleteTopic.bind(this); - this.handleDeleteMessages = this.handleDeleteMessages.bind(this); - this.handleLeave = this.handleLeave.bind(this); - this.handleBlock = this.handleBlock.bind(this); - this.handleReport = this.handleReport.bind(this); this.handleMemberSelected = this.handleMemberSelected.bind(this); - this.handleMoreInfo = this.handleMoreInfo.bind(this); - this.handleTagsUpdated = this.handleTagsUpdated.bind(this); this.handleContextMenu = this.handleContextMenu.bind(this); + this.handleBackNavigate = this.handleBackNavigate.bind(this); } // No need to separately handle component mount. @@ -177,19 +144,13 @@ class InfoView extends React.Component { this.previousSubsUpdated = topic.onSubsUpdated; topic.onSubsUpdated = this.onSubsUpdated; - - if (topic.getType() == 'grp') { - this.previousTagsUpdated = topic.onTagsUpdated; - topic.onTagsUpdated = this.onTagsUpdated; - } else { - this.previousTagsUpdated = undefined; - } } if (this.state.topic != props.topic) { this.setState({topic: props.topic}); this.resetDesc(topic, props); this.resetSubs(topic, props); + this.resetTags(topic); } } @@ -201,7 +162,6 @@ class InfoView extends React.Component { this.setState({topic: null}); topic.onMetaDesc = this.previousMetaDesc; topic.onSubsUpdated = this.previousSubsUpdated; - topic.onTagsUpdated = this.previousTagsUpdated; } resetSubs(topic, props) { @@ -230,6 +190,15 @@ class InfoView extends React.Component { const defacs = topic.getDefaultAccess() || {}; const acs = topic.getAccessMode(); + const badges = []; + if (topic.trusted) { + for (const [key, val] of Object.entries(topic.trusted)) { + if (val) { + badges.push(key); + } + } + } + this.setState({ owner: acs && acs.isOwner(), admin: acs && acs.isAdmin(), @@ -237,21 +206,30 @@ class InfoView extends React.Component { deleter: acs && acs.isDeleter(), muted: acs && acs.isMuted(), - fullName: topic.public ? topic.public.fn : undefined, - avatar: makeImageDataUrl(topic.public ? topic.public.photo : null), - private: topic.private ? topic.private.comment : null, + fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH), + description: _clip(topic.public ? topic.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH), + avatar: makeImageUrl(topic.public ? topic.public.photo : null), + trustedBadges: badges, + private: _clip(topic.private ? topic.private.comment : null, MAX_TITLE_LENGTH), + archived: topic.isArchived(), address: topic.name, groupTopic: topic.isGroupType(), - channel: topic.isChannelType(), - showMemberPanel: false, + channel: topic.isChannelType() || topic.chan, access: acs ? acs.getMode() : undefined, modeGiven: acs ? acs.getGiven() : undefined, modeWant: acs ? acs.getWant() : undefined, auth: defacs.auth, anon: defacs.anon }); + } - if (topic.getType() == 'grp' && acs && acs.isOwner()) { + resetTags(topic) { + if (topic.getType() != 'grp') { + return; + } + + const acs = topic.getAccessMode(); + if (acs && acs.isOwner()) { // Requesting tags: owner is editing the topic. topic.getMeta(topic.startMetaQuery().withTags().build()); } @@ -281,30 +259,6 @@ class InfoView extends React.Component { } } - onTagsUpdated(tags) { - this.setState({tags: tags}); - - if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) { - this.previousTagsUpdated(); - } - } - - handleFullNameUpdate(fn) { - fn = fn.trim().substring(0, MAX_TITLE_LENGTH); - if (this.state.fullName !== fn) { - this.setState({fullName: fn}); - this.props.onTopicDescUpdate(this.props.topic, theCard(fn, null), null); - } - } - - handlePrivateUpdate(comment) { - comment = comment.trim().substring(0, MAX_TITLE_LENGTH); - if (this.state.private !== comment) { - this.setState({private: comment}); - this.props.onTopicDescUpdate(this.props.topic, null, comment || Tinode.DEL_CHAR); - } - } - handleImageChanged(img) { this.setState({avatar: img}); this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null); @@ -315,13 +269,17 @@ class InfoView extends React.Component { this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P'); } - handlePermissionsChanged(perm) { - switch (this.state.showPermissionEditorFor) { + handleUnarchive(ignored, ignored2) { + this.props.onTopicUnArchive(this.props.topic); + } + + handlePermissionsChanged(which, perm) { + switch (which) { case 'auth': - this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm}); + this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {auth: perm}); break; case 'anon': - this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm}); + this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {anon: perm}); break; case 'mode': case 'want': @@ -335,7 +293,7 @@ class InfoView extends React.Component { break; } - this.setState({showPermissionEditorFor: undefined}); + this.handleBackNavigate(); } handleLaunchPermissionsEditor(which, uid) { @@ -359,15 +317,15 @@ class InfoView extends React.Component { toSkip += 'W'; } } - titleEdit = formatMessage(messages.requested); - titleCompare = formatMessage(messages.granted); + titleEdit = formatMessage(messages.perm_want); + titleCompare = formatMessage(messages.perm_given); break; case 'given': toEdit = this.state.modeGiven2; toCompare = this.state.modeWant2; toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO'; - titleEdit = formatMessage(messages.granted); - titleCompare = formatMessage(messages.requested); + titleEdit = formatMessage(messages.perm_given); + titleCompare = formatMessage(messages.perm_want); break; case 'auth': toEdit = this.state.auth; @@ -389,8 +347,8 @@ class InfoView extends React.Component { toEdit = user.acs.getGiven(); toCompare = user.acs.getWant(); toSkip = this.state.owner ? '' : 'O'; - titleEdit = formatMessage(messages.granted); - titleCompare = formatMessage(messages.requested); + titleEdit = formatMessage(messages.perm_given); + titleCompare = formatMessage(messages.perm_want); if (user.public) { userTitle = user.public.fn; userAvatar = user.public.photo; @@ -398,11 +356,10 @@ class InfoView extends React.Component { break; } default: - console.log("Unknown permission editing mode '" + which + "'"); - break; + console.error("Unknown permission editing mode '" + which + "'"); + return; } this.setState({ - showPermissionEditorFor: which, userPermissionsEdited: uid, userPermissionsTitle: userTitle, userPermissionsAvatar: userAvatar, @@ -412,109 +369,41 @@ class InfoView extends React.Component { immutablePermissionsTitle: titleCompare, editedPermissionsSkipped: toSkip, }); - } - - handleHidePermissionsEditor() { - this.setState({showPermissionEditorFor: undefined}); + this.props.onNavigate(`perm/${which}`); } handleShowAddMembers(e) { e.preventDefault(); this.props.onInitFind(); - this.setState({showMemberPanel: true}); - } - - handleHideAddMembers() { - this.setState({showMemberPanel: false}); + this.props.onNavigate('members'); } handleMemberUpdateRequest(members, added, removed) { this.props.onMemberUpdateRequest(this.props.topic, added, removed); - this.setState({showMemberPanel: false}); - } - - handleDeleteTopic(e) { - e.preventDefault(); - const {formatMessage} = this.props.intl; - this.props.onShowAlert( - formatMessage(messages.topic_delete), // title - formatMessage(messages.topic_delete_warning), // content - (() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm - null, // "OK" - true, // Show Reject button - null // "Cancel" - ); - } - - handleDeleteMessages(e) { - e.preventDefault(); - const {formatMessage} = this.props.intl; - this.props.onShowAlert( - formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages), // title - formatMessage(this.state.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content - (() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm - null, // "OK" - true, // Show Reject button - null // "Cancel" - ); - } - - handleLeave(e) { - e.preventDefault(); - const {formatMessage} = this.props.intl; - this.props.onShowAlert( - formatMessage(messages.leave_chat), // title - formatMessage(messages.leave_chat_warning), // content - (() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm - null, // "OK" - true, // Show Reject button - null // "Cancel" - ); - } - - handleBlock(e) { - e.preventDefault(); - const {formatMessage} = this.props.intl; - this.props.onShowAlert( - formatMessage(messages.block_contact), // title - formatMessage(messages.block_contact_warning), // content - (() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm - null, // "OK" - true, // Show Reject button - null // "Cancel" - ); - } - - handleReport(e) { - e.preventDefault(); - const {formatMessage} = this.props.intl; - this.props.onShowAlert( - formatMessage(messages.report_chat), // title - formatMessage(messages.report_chat_warning), // content - (() => { this.props.onReportTopic(this.props.topic); }), // onConfirm - null, // "OK" - true, // Show Reject button - null // "Cancel" - ); + this.props.onNavigate('info'); } handleMemberSelected(uid) { this.setState({selectedContact: uid}); } - handleMoreInfo(open) { - this.setState({moreInfoExpanded: open}); - } - - handleTagsUpdated(tags) { - if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) { - this.props.onTopicTagsUpdate(this.props.topic, tags); + handleBackNavigate() { + const args = (this.props.panel || 'info').split('/'); + if (args[0] == 'info') { + this.props.onNavigate(null); + } else if (args[0] == 'perm') { + if (args[1] == 'user') { + this.props.onNavigate('info'); + } else { + this.props.onNavigate('security'); + } + } else { + this.props.onNavigate('info'); } } handleContextMenu(params) { const {formatMessage} = this.props.intl; - const instance = this; const topic = this.props.tinode.getTopic(this.props.topic); if (!topic) { return; @@ -524,14 +413,19 @@ class InfoView extends React.Component { return; } + const isMe = this.props.tinode.isMe(params.topicName); const menuItems = [ - {title: formatMessage(messages.edit_permissions), handler: function() { - instance.handleLaunchPermissionsEditor('user', params.topicName); - }}, - 'member_delete', - user.acs.isMuted() ? 'member_unmute' : 'member_mute', - user.acs.isJoiner() ? 'member_block' : 'member_unblock' + {title: formatMessage(messages.edit_permissions), handler: () => { + this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName); + }} ]; + if (!isMe) { + menuItems.push('member_delete'); + } + menuItems.push(user.acs.isMuted() ? 'member_unmute' : 'member_mute'); + if (!isMe) { + menuItems.push(user.acs.isJoiner() ? 'member_block' : 'member_unblock'); + } this.props.showContextMenu({ topicName: this.props.topic, x: params.x, @@ -540,16 +434,20 @@ class InfoView extends React.Component { } render() { + const args = (this.props.panel || 'info').split('/'); + const view = args[0]; + args.shift(); + const {formatMessage} = this.props.intl; + const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view]) + || messages['info']); return (
-
- -
+
{panelTitle}
- +
{this.props.displayMobile ? @@ -557,18 +455,20 @@ class InfoView extends React.Component { level={this.props.errorLevel} text={this.props.errorText} onClearError={this.props.onError} /> : null} - {this.state.showMemberPanel ? + {view == 'members' ? : - this.state.showPermissionEditorFor ? + view == 'perm' && args.length > 0 ? + onSubmit={(mode) => this.handlePermissionsChanged(args[0], mode)} + onCancel={this.handleBackNavigate} /> + : + view == 'general' ? + + : + view == 'security' ? + :
-
-
+
+ {e.preventDefault(); this.props.onNavigate('general');}}> + edit  + + +
+ +
+
-
Unknown} - readOnly={!this.state.owner} - value={this.state.fullName} - required={true} - onFinished={this.handleFullNameUpdate} />
-
-
+ {this.state.private ? +
+
-
-
- { - (private_placeholder) => - } +
+
{this.state.private}
+ : null + } +
+   + {this.state.address}
- +
+ +
+ {this.state.description ? +
+ +
{this.state.description}
+
: null}
-
+
+ + +
+ {this.state.archived ?
- +
- { - (more) => - } - {this.state.moreInfoExpanded ? -
-
- - {this.state.address} -
- {this.state.groupTopic ? -
- - - {this.state.access} - -
- : -
-
- -
-
-
-   - {this.state.access} -
-
{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}: -   - {this.state.modeGiven2} - -
-
-
- } - {this.state.sharer && (this.state.auth || this.state.anon) ? -
-
- -
-
-
Auth: {this.state.admin ? - - {this.state.auth} - - : - {this.state.auth} - } -
-
Anon: {this.state.admin ? - - {this.state.anon} - - : - {this.state.anon} - } -
-
-
- : - null - } -
- : - null - } -
-
- {this.state.owner ? - <> - { - (tags) => - } -
- : null } -
- {!this.state.channel ? - - delete_outline  { - formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages) - } - - : - null - } - {this.state.owner ? - - delete  {formatMessage(messages.topic_delete)} - - : - - exit_to_app  {formatMessage(messages.leave_chat)} - - } - {!this.state.groupTopic ? - - block  {formatMessage(messages.block_contact)} - - : - null - } - {!this.state.owner ? - - report  {formatMessage(messages.report_chat)} - - : - null - } +
+ {this.state.groupTopic && this.state.sharer ? <>
-
-
- -
- - { - (no_members) => - } +
+
+ + { + (no_members) => + } : null diff --git a/src/views/logo-view.jsx b/src/views/logo-view.jsx index 01705ac1..82f044ea 100644 --- a/src/views/logo-view.jsx +++ b/src/views/logo-view.jsx @@ -16,7 +16,7 @@ export default class LogoView extends React.PureComponent {

Tinode Web

-

{version}

{this.props.serverVersion} ({this.props.serverAddress})

diff --git a/src/views/messages-view.jsx b/src/views/messages-view.jsx index dda4fac5..2aa3302c 100644 --- a/src/views/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -5,6 +5,7 @@ import Tinode from 'tinode-sdk'; const Drafty = Tinode.Drafty; import ChatMessage from '../widgets/chat-message.jsx'; +import ContactBadges from '../widgets/contact-badges.jsx'; import DocPreview from '../widgets/doc-preview.jsx'; import ErrorPanel from '../widgets/error-panel.jsx'; import GroupSubs from '../widgets/group-subs.jsx'; @@ -15,12 +16,14 @@ import LoadSpinner from '../widgets/load-spinner.jsx'; import LogoView from './logo-view.jsx'; import SendMessage from '../widgets/send-message.jsx'; -import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE, - MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js'; -import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64, - imageScaled, makeImageDataUrl } from '../lib/blob-helpers.js'; +import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, IMAGE_THUMBNAIL_DIM, KEYPRESS_DELAY, + MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, + READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js'; +import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64, + imageScaled, makeImageUrl } from '../lib/blob-helpers.js'; import HashNavigation from '../lib/navigation.js'; -import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js'; +import { bytesToHumanSize, shortDateFormat, letterTileColorId } from '../lib/strformat.js'; +import { sanitizeImageUrl } from '../lib/utils.js'; // Run timer with this frequency (ms) for checking notification queue. const NOTIFICATION_EXEC_INTERVAL = 300; @@ -50,7 +53,12 @@ const messages = defineMessages({ id: 'file_attachment_too_large', defaultMessage: 'The file size {size} exceeds the {limit} limit.', description: 'Error message when attachment is too large' - } + }, + invalid_content: { + id: 'invalid_content', + defaultMessage: 'invalid content', + description: 'Shown when the message is unreadable' + }, }); // Checks if the access permissions are granted but not yet accepted. @@ -77,9 +85,12 @@ class MessagesView extends React.Component { this.state = MessagesView.getDerivedStateFromProps(props, {}); this.leave = this.leave.bind(this); + this.sendMessage = this.sendMessage.bind(this); + this.retrySend = this.retrySend.bind(this); this.sendImageAttachment = this.sendImageAttachment.bind(this); this.sendFileAttachment = this.sendFileAttachment.bind(this); this.sendKeyPress = this.sendKeyPress.bind(this); + this.subscribe = this.subscribe.bind(this); this.handleScrollReference = this.handleScrollReference.bind(this); this.handleScrollEvent = this.handleScrollEvent.bind(this); this.handleDescChange = this.handleDescChange.bind(this); @@ -91,7 +102,7 @@ class MessagesView extends React.Component { this.handleClosePreview = this.handleClosePreview.bind(this); this.handleFormResponse = this.handleFormResponse.bind(this); this.handleContextClick = this.handleContextClick.bind(this); - this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this); + this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this); this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this); this.handleEnablePeer = this.handleEnablePeer.bind(this); this.handleAttachFile = this.handleAttachFile.bind(this); @@ -100,12 +111,27 @@ class MessagesView extends React.Component { this.postReadNotification = this.postReadNotification.bind(this); this.clearNotificationQueue = this.clearNotificationQueue.bind(this); + this.handlePickReply = this.handlePickReply.bind(this); + this.handleCancelReply = this.handleCancelReply.bind(this); + this.handleQuoteClick = this.handleQuoteClick.bind(this); + + this.chatMessageRefs = {}; + this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this); + this.readNotificationQueue = []; this.readNotificationTimer = null; } + getOrCreateMessageRef(seqId) { + if (this.chatMessageRefs.hasOwnProperty(seqId)) { + return this.chatMessageRefs[seqId]; + } + const ref = React.createRef(); + this.chatMessageRefs[seqId] = ref; + return ref; + } + componentDidMount() { - // this.propsChange(this.props, this.state); if (this.messagesScroller) { this.messagesScroller.addEventListener('scroll', this.handleScrollEvent); } @@ -157,49 +183,8 @@ class MessagesView extends React.Component { this.postReadNotification(0); } - if (topic && !topic.isSubscribed() && this.props.ready && - ((this.state.topic != prevState.topic) || !prevProps.ready)) { - // Is this a new topic? - const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic); - - // Don't request the tags. They are useless unless the user - // is the owner and is editing the topic. - let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub(); - if (this.state.isReader || newTopic) { - // Reading is either permitted or we don't know because it's a new topic. Ask for messages. - getQuery = getQuery.withLaterData(MESSAGES_PAGE); - if (this.state.isReader) { - getQuery = getQuery.withLaterDel(); - } - // And show "loading" spinner. - this.setState({ fetchingMessages: true }); - } - const setQuery = newTopic ? this.props.newTopicParams : undefined; - topic.subscribe(getQuery.build(), setQuery) - .then((ctrl) => { - if (ctrl.code == 303) { - // Redirect to another topic requested. - HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic)); - return; - } - if (this.state.topic != ctrl.topic) { - this.setState({topic: ctrl.topic}); - } - this.props.onNewTopicCreated(this.props.topic, ctrl.topic); - // If there are unsent messages, try sending them now. - topic.queuedMessages((pub) => { - if (!pub._sending && topic.isSubscribed()) { - topic.publishMessage(pub); - } - }); - }) - .catch((err) => { - console.log("Failed subscription to", this.state.topic); - this.props.onError(err.message, 'err'); - const blankState = MessagesView.getDerivedStateFromProps({}, {}); - blankState.title = this.props.intl.formatMessage(messages.not_found); - this.setState(blankState); - }); + if ((this.state.topic != prevState.topic) || !prevProps.ready) { + this.subscribe(topic); } } @@ -214,6 +199,9 @@ class MessagesView extends React.Component { topic: null, title: '', avatar: null, + isVerified: false, + isStaff: false, + isDangerous: false, docPreview: null, imagePreview: null, imagePostview: null, @@ -221,10 +209,23 @@ class MessagesView extends React.Component { scrollPosition: 0, fetchingMessages: false, peerMessagingDisabled: false, - channel: false + channel: false, + reply: null }; } else if (nextProps.topic != prevState.topic) { const topic = nextProps.tinode.getTopic(nextProps.topic); + + let reply = null; + if (nextProps.forwardMessage) { + // We are forwarding a message. Show preview. + const preview = nextProps.forwardMessage.preview; + reply = { + content: preview, + forwarded: nextProps.forwardMessage.head.forwarded, + seq: null + }; + } + nextState = { topic: nextProps.topic, docPreview: null, @@ -232,7 +233,8 @@ class MessagesView extends React.Component { imagePostview: null, typingIndicator: false, scrollPosition: 0, - fetchingMessages: false + fetchingMessages: false, + reply: reply }; if (topic) { @@ -254,7 +256,7 @@ class MessagesView extends React.Component { if (topic.public) { Object.assign(nextState, { title: topic.public.fn, - avatar: makeImageDataUrl(topic.public.photo) + avatar: makeImageUrl(topic.public.photo) }); } else { Object.assign(nextState, { @@ -332,6 +334,55 @@ class MessagesView extends React.Component { return nextState; } + subscribe(topic) { + if (!topic || topic.isSubscribed() || !this.props.ready) { + return; + } + + // Is this a new topic? + const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic); + + // Don't request the tags. They are useless unless the user + // is the owner and is editing the topic. + let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub(); + if (this.state.isReader || newTopic) { + // Reading is either permitted or we don't know because it's a new topic. Ask for messages. + getQuery = getQuery.withLaterData(MESSAGES_PAGE); + if (this.state.isReader) { + getQuery = getQuery.withLaterDel(); + } + // And show "loading" spinner. + this.setState({ fetchingMessages: true }); + } + + const setQuery = newTopic ? this.props.newTopicParams : undefined; + topic.subscribe(getQuery.build(), setQuery) + .then((ctrl) => { + if (ctrl.code == 303) { + // Redirect to another topic requested. + HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic)); + return; + } + if (this.state.topic != ctrl.topic) { + this.setState({topic: ctrl.topic}); + } + this.props.onNewTopicCreated(this.props.topic, ctrl.topic); + // If there are unsent messages, try sending them now. + topic.queuedMessages((pub) => { + if (!pub._sending && topic.isSubscribed()) { + this.retrySend(pub); + } + }); + }) + .catch((err) => { + console.error("Failed subscription to", this.state.topic); + this.props.onError(err.message, 'err'); + const blankState = MessagesView.getDerivedStateFromProps({}, {}); + blankState.title = this.props.intl.formatMessage(messages.not_found); + this.setState(blankState); + }); + } + leave(oldTopicName) { if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) { return; @@ -385,7 +436,7 @@ class MessagesView extends React.Component { if (desc.public) { this.setState({ title: desc.public.fn, - avatar: makeImageDataUrl(desc.public.photo) + avatar: makeImageUrl(desc.public.photo) }); } else { this.setState({ @@ -500,7 +551,8 @@ class MessagesView extends React.Component { return; } - this.setState({messageCount: topic.messageCount()}); + clearTimeout(this.keyPressTimer) + this.setState({messageCount: topic.messageCount(), typingIndicator: false}); // Scroll to the bottom if the message is added to the end of the message list. // TODO: This should be replaced by showing a "scroll to bottom" button. @@ -530,9 +582,8 @@ class MessagesView extends React.Component { switch (info.what) { case 'kp': { clearTimeout(this.keyPressTimer); - var instance = this; - this.keyPressTimer = setTimeout(function() { - instance.setState({typingIndicator: false}); + this.keyPressTimer = setTimeout(() => { + this.setState({typingIndicator: false}); }, KEYPRESS_DELAY + 1000); if (!this.state.typingIndicator) { this.setState({typingIndicator: true}); @@ -545,7 +596,7 @@ class MessagesView extends React.Component { this.forceUpdate(); break; default: - console.log("Other change in topic: ", info.what); + console.info("Other change in topic: ", info.what); } } @@ -562,7 +613,7 @@ class MessagesView extends React.Component { handleFormResponse(action, text, data) { if (action == 'pub') { - this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data)); + this.sendMessage(Drafty.attachJSON(Drafty.parse(text), data)); } else if (action == 'url') { const url = new URL(data.ref); const params = url.searchParams; @@ -581,7 +632,7 @@ class MessagesView extends React.Component { url.search = params; window.open(url, '_blank'); } else { - console.log("Unknown action in form", action); + console.info("Unknown action in form", action); } } @@ -591,7 +642,11 @@ class MessagesView extends React.Component { this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX }); } - handleShowContextMenuMessage(params, messageSpecificMenuItems) { + handleShowMessageContextMenu(params, messageSpecificMenuItems) { + if (params.userFrom == 'chan') { + params.userFrom = this.state.topic; + params.userName = this.state.title; + } params.topicName = this.state.topic; const menuItems = messageSpecificMenuItems || []; const topic = this.props.tinode.getTopic(params.topicName); @@ -623,6 +678,32 @@ class MessagesView extends React.Component { } } + // sendMessage sends the message with an optional subscription to topic first. + sendMessage(msg, uploadCompletionPromise, uploader) { + let head; + if (this.props.forwardMessage) { + // We are forwarding a message. + msg = this.props.forwardMessage.msg; + head = this.props.forwardMessage.head; + this.handleCancelReply(); + } else if (this.state.reply && this.state.reply.content) { + // We are replying to a message in this topic. + head = {reply: '' + this.state.reply.seq}; + // Turn it into Drafty so we can make a quoted Drafty object later. + if (typeof msg == 'string') { + msg = Drafty.parse(msg); + } + msg = Drafty.append(this.state.reply.content, msg); + this.handleCancelReply(); + } + this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); + } + + // Retry sending a message. + retrySend(pub) { + this.props.sendMessage(pub.content, undefined, undefined, pub.head); + } + // Send attachment as Drafty message: // - if file is too large, upload it and send a s link. // - if file is small enough, just send it in-band. @@ -634,10 +715,6 @@ class MessagesView extends React.Component { if (file.size > maxInbandAttachmentSize) { // Too large to send inband - uploading out of band and sending as a link. const uploader = this.props.tinode.getLargeFileHelper(); - if (!uploader) { - this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); - return; - } const uploadCompletionPromise = uploader.upload(file); const msg = Drafty.attachFile(null, { mime: file.type, @@ -646,15 +723,12 @@ class MessagesView extends React.Component { urlPromise: uploadCompletionPromise }); // Pass data and the uploader to the TinodeWeb. - this.props.sendMessage(msg, uploadCompletionPromise, uploader); + this.sendMessage(msg, uploadCompletionPromise, uploader); } else { // Small enough to send inband. - fileToBase64(file, - (mime, bits, fname) => { - this.props.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname})); - }, - this.props.onError - ); + fileToBase64(file) + .then(b64 => this.sendMessage(Drafty.attachFile(null, {mime: b64.mime, data: b64.bits, filename: b64.name}))) + .catch(err => this.props.onError(err)); } } @@ -667,21 +741,23 @@ class MessagesView extends React.Component { this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large, {size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err'); } else { - this.setState({ docPreview: { - file: file, - filename: file.name, - size: file.size, - type: file.type - }}); + this.setState({ + docPreview: { + file: file, + name: file.name, + size: file.size, + type: file.type + } + }); } } // sendImageAttachment sends the image bits inband as Drafty message. sendImageAttachment(caption, blob) { - const mime = this.state.imagePreview.type; + const mime = this.state.imagePreview.mime; const width = this.state.imagePreview.width; const height = this.state.imagePreview.height; - const fname = this.state.imagePreview.filename; + const fname = this.state.imagePreview.name; // Server-provided limit reduced for base64 encoding and overhead. const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize', @@ -697,13 +773,13 @@ class MessagesView extends React.Component { const uploadCompletionPromise = uploader.upload(blob); // Make small preview to show while uploading. - imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false, - (tinyMine, tinyBlob) => { - // Convert tiny image into base64 for serialization and previewing. - blobToBase64(tinyBlob, (blobMime, tinyBits64) => { + imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false) + // Convert tiny image into base64 for serialization and previewing. + .then(scaled => blobToBase64(scaled.blob)) + .then(b64 => { let msg = Drafty.insertImage(null, 0, { mime: mime, - _tempPreview: tinyBits64, // This preview will not be serialized. + _tempPreview: b64.bits, // This preview will not be serialized. width: width, height: height, filename: fname, @@ -712,36 +788,33 @@ class MessagesView extends React.Component { }); if (caption) { msg = Drafty.appendLineBreak(msg); - msg = Drafty.append(msg, Drafty.init(caption)); + msg = Drafty.append(msg, Drafty.parse(caption)); } // Pass data and the uploader to the TinodeWeb. - this.props.sendMessage(msg, uploadCompletionPromise, uploader); - } - )}, - // Failure - (err) => { + this.sendMessage(msg, uploadCompletionPromise, uploader); + }).catch((err) => { this.props.onError(err, 'err'); - } - ); - return; + }); + return; } // Upload the image if it's too big to be send inband. - blobToBase64(blob, (blobMime, bits64) => { - let msg = Drafty.insertImage(null, 0, { - mime: blobMime, - preview: bits64, // Serializable preview - width: width, - height: height, - filename: fname, - size: blob.size + blobToBase64(blob) + .then(b64 => { + let msg = Drafty.insertImage(null, 0, { + mime: b64.mime, + preview: b64.bits, // Serializable preview + width: width, + height: height, + filename: fname, + size: blob.size + }); + if (caption) { + msg = Drafty.appendLineBreak(msg); + msg = Drafty.append(msg, Drafty.parse(caption)); + } + this.sendMessage(msg); }); - if (caption) { - msg = Drafty.appendLineBreak(msg); - msg = Drafty.append(msg, Drafty.init(caption)); - } - this.props.sendMessage(msg); - }); } // handleAttachImage method is called when [Attach image] button is clicked. @@ -749,24 +822,20 @@ class MessagesView extends React.Component { const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE); // Get image dimensions and size, optionally scale it down. - imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false, - // Success - (mime, blob, width, height, fname) => { + imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false) + .then(scaled => { this.setState({imagePreview: { - url: URL.createObjectURL(blob), - blob: blob, - filename: fname, - width: width, - height: height, - size: blob.size, - type: mime + url: URL.createObjectURL(scaled.blob), + blob: scaled.blob, + name: scaled.name, + width: scaled.width, + height: scaled.height, + size: scaled.blob.size, + mime: scaled.mime }}); - }, - // Failure - (err) => { + }).catch(err => { this.props.onError(err, 'err'); - } - ); + }); } handleCancelUpload(seq, uploader) { @@ -778,6 +847,49 @@ class MessagesView extends React.Component { uploader.cancel(); } + handlePickReply(seq, content, forwarded, senderId, senderName) { + this.setState({reply: null}); + + if (!seq || !content) { + return; + } + + content = forwarded ? + Drafty.forwardedContent(content) : + typeof content == 'string' ? Drafty.init(content) : content; + if (Drafty.isValid(content)) { + content = Drafty.preview(content, QUOTED_REPLY_LENGTH, undefined, !forwarded); + } else { + // /!\ invalid content. + content = Drafty.append(Drafty.init('\u26A0 '), + Drafty.wrapInto(this.props.intl.formatMessage(messages.invalid_content), 'EM')); + } + + this.setState({ + reply: { + content: Drafty.quote(senderName, senderId, content), + seq: seq + } + }); + this.props.onCancelForwardMessage(); + } + + handleCancelReply() { + this.setState({reply: null}); + this.props.onCancelForwardMessage(); + } + + handleQuoteClick(replyToSeq) { + const ref = this.getOrCreateMessageRef(replyToSeq); + if (ref && ref.current) { + ref.current.scrollIntoView({block: "center", behavior: "smooth"}); + ref.current.classList.add('flash'); + setTimeout(() => { ref.current.classList.remove('flash') } , 1000); + } else { + console.error("Unresolved message ref", replyToSeq); + } + } + render() { const {formatMessage} = this.props.intl; @@ -797,6 +909,9 @@ class MessagesView extends React.Component { component2 = ( ); @@ -812,6 +927,9 @@ class MessagesView extends React.Component { component2 = ( ); @@ -819,11 +937,23 @@ class MessagesView extends React.Component { const topic = this.props.tinode.getTopic(this.state.topic); const isChannel = topic.isChannelType(); const groupTopic = topic.isGroupType() && !isChannel; + const icon_badges = []; + if (topic.trusted) { + if (topic.trusted.verified) { + icon_badges.push({icon: 'verified', color: 'badge-inv'}); + } + if (topic.trusted.staff) { + icon_badges.push({icon: 'staff', color: 'badge-inv'}); + } + if (topic.trusted.danger) { + icon_badges.push({icon: 'dangerous', color: 'badge-inv'}); + } + } let messageNodes = []; let previousFrom = null; let chatBoxClass = null; topic.messages((msg, prev, next, i) => { - let nextFrom = next ? (next.from || null) : 'chan'; + let nextFrom = next ? (next.from || 'chan') : null; let sequence = 'single'; let thisFrom = msg.from || 'chan'; @@ -841,40 +971,50 @@ class MessagesView extends React.Component { const isReply = !(thisFrom == this.props.myUserId); const deliveryStatus = topic.msgStatus(msg, true); - let userName, userAvatar, userFrom; - if (groupTopic) { - const user = topic.userDesc(thisFrom); - if (user && user.public) { - userName = user.public.fn; - userAvatar = makeImageDataUrl(user.public.photo); - } - userFrom = thisFrom; - chatBoxClass='chat-box group'; - } else { - chatBoxClass='chat-box'; + let userFrom = thisFrom, userName, userAvatar; + const user = topic.userDesc(thisFrom); + if (user && user.public) { + userName = user.public.fn; + userAvatar = makeImageUrl(user.public.photo); + } + chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box'; + + // Ref for this chat message. + const ref = this.getOrCreateMessageRef(msg.seq); + let replyToSeq = msg.head ? parseInt(msg.head.reply) : null; + if (!replyToSeq || isNaN(replyToSeq)) { + replyToSeq = null; } messageNodes.push( ); }); @@ -908,6 +1048,7 @@ class MessagesView extends React.Component { null}
@@ -918,11 +1059,12 @@ class MessagesView extends React.Component { this.state.title || - }
+ }
{lastSeen}
{groupTopic ? :
} @@ -968,12 +1110,17 @@ class MessagesView extends React.Component { : } + onSendMessage={this.sendMessage} + onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile} + onAttachImage={this.props.forwardMessage ? null : this.handleAttachImage} + onError={this.props.onError} + reply={this.state.reply} + onQuoteClick={this.handleQuoteClick} + onCancelReply={this.handleCancelReply} />} ); } diff --git a/src/views/new-topic-view.jsx b/src/views/new-topic-view.jsx index 2cf6c843..6cebbcbb 100644 --- a/src/views/new-topic-view.jsx +++ b/src/views/new-topic-view.jsx @@ -22,7 +22,12 @@ const messages = defineMessages({ id: "search_no_results", defaultMessage: "Search returned no results", description: "Text shown in contacts view when query returned no results." - } + }, + search_placeholder: { + id: "search_placeholder", + defaultMessage: "List like email:alice@example.com, tel:17025550003...", + description: "Placeholder in contacts search field" + }, }); class NewTopicView extends React.Component { @@ -56,16 +61,17 @@ class NewTopicView extends React.Component { this.setState({searchQuery: Tinode.isNullValue(query) ? null : query}); } - handleContactSelected(sel) { - if (this.state.tabSelected === 'p2p') { + handleContactSelected(uid) { + if (this.state.tabSelected == 'p2p') { HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab')); - this.props.onCreateTopic(sel); + this.props.onCreateTopic(uid); } } - handleNewGroupSubmit(name, dataUrl, priv, tags, isChannel) { + handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) { HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab')); - this.props.onCreateTopic(undefined, theCard(name, dataUrl), priv, tags, isChannel); + this.props.onCreateTopic(undefined, + {public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel); } handleGroupByID(topicName) { @@ -77,6 +83,7 @@ class NewTopicView extends React.Component { const {formatMessage} = this.props.intl; const no_contacts_placeholder = formatMessage(this.state.searchQuery ? messages.search_no_results : messages.search_for_contacts); + const search_placeholder = formatMessage(messages.search_placeholder); return (
    @@ -110,8 +117,10 @@ class NewTopicView extends React.Component {
    : @@ -159,21 +169,22 @@ class SidepanelView extends React.Component { onUpdate={this.props.onGlobalSettings} /> : view === 'edit' ? - : + trustedBadges={this.props.trustedBadges} + onNavigate={this.props.onNavigate} /> : - view === 'general' ? - : view === 'notif' ? @@ -189,7 +200,8 @@ class SidepanelView extends React.Component { view === 'security' ? : + onReset={this.props.onResetPassword} + onCancel={this.props.onCancel} /> : null}
    ); diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index b08d61eb..fa8bb79b 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -1,6 +1,5 @@ // The top-level class to hold all functionality together. import React from 'react'; -import ReactDOM from 'react-dom'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import * as firebase from 'firebase/app'; @@ -10,15 +9,16 @@ import Tinode from 'tinode-sdk'; import Alert from '../widgets/alert.jsx'; import ContextMenu from '../widgets/context-menu.jsx'; +import ForwardDialog from '../widgets/forward-dialog.jsx'; import InfoView from './info-view.jsx'; import MessagesView from './messages-view.jsx'; import SidepanelView from './sidepanel-view.jsx'; -import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, LOGGING_ENABLED, - MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js'; +import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, FORWARDED_PREVIEW_LENGTH, LOGGING_ENABLED, + MEDIA_BREAKPOINT, QUOTED_REPLY_LENGTH, RECEIVED_DELAY } from '../config.js'; import { PACKAGE_VERSION } from '../version.js'; -import { base64ReEncode, makeImageDataUrl } from '../lib/blob-helpers.js'; +import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js'; import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js'; import LocalStorageUtil from '../lib/local-storage.js'; import HashNavigation from '../lib/navigation.js'; @@ -70,6 +70,8 @@ class TinodeWeb extends React.Component { constructor(props) { super(props); + this.selfRef = React.createRef(); + this.state = this.getBlankState(); this.handleResize = this.handleResize.bind(this); @@ -100,7 +102,7 @@ class TinodeWeb extends React.Component { this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this); this.handleNewAccount = this.handleNewAccount.bind(this); this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this); - this.handleUpdateAccountRequest = this.handleUpdateAccountRequest.bind(this); + this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this); this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this); this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this); this.handleSettings = this.handleSettings.bind(this); @@ -119,8 +121,9 @@ class TinodeWeb extends React.Component { this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this); this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this); this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this); + this.handleUnarchive = this.handleUnarchive.bind(this); this.handleChangePermissions = this.handleChangePermissions.bind(this); - this.handleTagsUpdated = this.handleTagsUpdated.bind(this); + this.handleTagsUpdateRequest = this.handleTagsUpdateRequest.bind(this); this.handleLogout = this.handleLogout.bind(this); this.handleDeleteAccount = this.handleDeleteAccount.bind(this); this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this); @@ -133,12 +136,16 @@ class TinodeWeb extends React.Component { this.handleHideContextMenu = this.handleHideContextMenu.bind(this); this.handleShowAlert = this.handleShowAlert.bind(this); this.handleShowInfoView = this.handleShowInfoView.bind(this); - this.handleHideInfoView = this.handleHideInfoView.bind(this); this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this); this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this); this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this); this.handleResetPassword = this.handleResetPassword.bind(this); this.handleContextMenuAction = this.handleContextMenuAction.bind(this); + + this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this); + this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this); + + this.sendMessageToTopic = this.sendMessageToTopic.bind(this); } getBlankState() { @@ -157,7 +164,7 @@ class TinodeWeb extends React.Component { // "On" is the default, so saving the "off" state. messageSounds: !settings.messageSoundsOff, incognitoMode: false, - desktopAlerts: persist && settings.desktopAlerts, + desktopAlerts: persist && !!settings.desktopAlerts, desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) && (typeof firebase != 'undefined') && (typeof navigator != 'undefined') && (typeof FIREBASE_INIT != 'undefined'), @@ -173,6 +180,7 @@ class TinodeWeb extends React.Component { sidePanelSelected: 'login', sidePanelTitle: null, sidePanelAvatar: null, + myTrustedBadges: [], loadSpinnerVisible: false, login: '', @@ -186,7 +194,7 @@ class TinodeWeb extends React.Component { newTopicParams: null, loginDisabled: false, displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT), - showInfoPanel: false, + infoPanel: undefined, mobilePanel: 'sidepanel', contextMenuVisible: false, @@ -195,6 +203,9 @@ class TinodeWeb extends React.Component { contextMenuParams: null, contextMenuItems: [], + forwardDialogVisible: false, + forwardMessage: null, + // Modal alert dialog. alertVisible: false, alertParams: {}, @@ -224,7 +235,7 @@ class TinodeWeb extends React.Component { serviceWorkerChannel.addEventListener('message', this.handlePushMessage); } else { // Safari is broken by design. - console.warn('Your browser does not support BroadcastChannel. Some features will not be available'); + console.warn("Your browser does not support BroadcastChannel. Some features will not be available"); } // Window/tab visible or invisible for pausing timers. @@ -386,7 +397,7 @@ class TinodeWeb extends React.Component { const hash = HashNavigation.parseUrlHash(window.location.hash); if (hash.path && hash.path.length > 0) { // Left-side panel selector. - if (['register','settings','edit','notif','security','support','general', + if (['register','settings','edit','notif','security','support','general','crop', 'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) { this.setState({sidePanelSelected: hash.path[0]}); } else { @@ -424,7 +435,7 @@ class TinodeWeb extends React.Component { // Additional parameters of panels. this.setState({ - showInfoPanel: hash.params.info, + infoPanel: hash.params.info, newTopicTabSelected: hash.params.tab }); } @@ -550,6 +561,7 @@ class TinodeWeb extends React.Component { errorLevel: err && err.message ? 'err' : 'warn', loginDisabled: false, contextMenuVisible: false, + forwardDialogVisible: false, serverVersion: "no connection" }); } @@ -564,8 +576,9 @@ class TinodeWeb extends React.Component { cred = Tinode.credential(cred); // Try to login with login/password. If they are not available, try token. If no token, ask for login/password. let promise = null; - const token = this.tinode.getAuthToken(); + let token = this.tinode.getAuthToken(); if (login && password) { + token = null; this.setState({password: null}); promise = this.tinode.loginBasic(login, password, cred); } else if (token) { @@ -593,7 +606,9 @@ class TinodeWeb extends React.Component { autoLogin: false }); this.handleError(err.message, 'err'); - localStorage.removeItem('auth-token'); + if (token) { + this.handleLogout(); + } HashNavigation.navigateTo(''); }); } else { @@ -661,7 +676,18 @@ class TinodeWeb extends React.Component { if (desc.public) { this.setState({ sidePanelTitle: desc.public.fn, - sidePanelAvatar: makeImageDataUrl(desc.public.photo) + sidePanelAvatar: makeImageUrl(desc.public.photo) + }); + } + if (desc.trusted) { + const badges = []; + for (const [key, val] of Object.entries(desc.trusted)) { + if (val) { + badges.push(key); + } + } + this.setState({ + myTrustedBadges: badges, }); } if (desc.acs) { @@ -769,6 +795,7 @@ class TinodeWeb extends React.Component { // Contacts expect c.topic to be set. c.topic = c.name; } + newState.chatList.push(c); if (this.state.topicSelected == c.topic) { newState.topicSelectedOnline = c.online; @@ -849,13 +876,14 @@ class TinodeWeb extends React.Component { errorText: '', errorLevel: null, mobilePanel: 'topic-view', - showInfoPanel: false + infoPanel: undefined }); // Different contact selected. if (this.state.topicSelected != topicName) { this.setState({ topicSelectedOnline: this.tinode.isTopicOnline(topicName), - topicSelectedAcs: this.tinode.getTopicAccessMode(topicName) + topicSelectedAcs: this.tinode.getTopicAccessMode(topicName), + forwardMessage: null }); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName)); } @@ -867,7 +895,8 @@ class TinodeWeb extends React.Component { mobilePanel: 'sidepanel', topicSelectedOnline: false, topicSelectedAcs: null, - showInfoPanel: false + infoPanel: undefined, + forwardMessage: null }); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null)); @@ -887,18 +916,36 @@ class TinodeWeb extends React.Component { // - msg - Drafty message with content // - promise - Promise to be resolved when the upload is completed // - uploader - for tracking progress - handleSendMessage(msg, promise, uploader) { + // - head - head dictionary to be attached to the message + handleSendMessage(msg, promise, uploader, head) { const topic = this.tinode.getTopic(this.state.topicSelected); + this.sendMessageToTopic(topic, msg, promise, uploader, head); + } + sendMessageToTopic(topic, msg, promise, uploader, head) { msg = topic.createMessage(msg, false); // The uploader is used to show progress. msg._uploader = uploader; + if (head) { + msg.head = Object.assign(msg.head || {}, head); + } + if (!topic.isSubscribed()) { + // Topic is not subscribed yet. Subscribe. if (!promise) { promise = Promise.resolve(); } - promise = promise.then(() => { return topic.subscribe(); }); + promise = promise + .then(() => topic.subscribe()) + .then(() => { + // If there are unsent messages, try sending them now. + topic.queuedMessages((pub) => { + if (!pub._sending && topic.isSubscribed()) { + this.sendMessage(pub); + } + }); + }); } if (promise) { @@ -984,32 +1031,15 @@ class TinodeWeb extends React.Component { }); } - handleUpdateAccountRequest(password, pub, defacs) { - this.handleError(); - - if (pub || defacs) { - const params = {}; - if (pub) { - params.public = pub; - } - if (defacs) { - params.defacs = defacs; - } - this.tinode.getMeTopic().setMeta({desc: params}).catch((err) => { - this.handleError(err.message, 'err'); - }); - } - if (password) { - this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => { - this.handleError(err.message, 'err'); - }); - } - } - handleToggleIncognitoMode(on) { + // Make state undefined. + this.setState({incognitoMode: null}); + const me = this.tinode.getMeTopic(); const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant(); me.setMeta({sub: {mode: am}}).catch((err) => { + // Request failed, keep existing state. + this.setState({incognitoMode: !on}); this.handleError(err.message, 'err'); }); } @@ -1070,6 +1100,7 @@ class TinodeWeb extends React.Component { togglePushToken(enabled) { if (enabled) { + this.setState({desktopAlerts: null}); if (!this.state.firebaseToken) { const fcm = this.fbPush ? Promise.resolve() : @@ -1079,7 +1110,7 @@ class TinodeWeb extends React.Component { }).then(() => { this.requestPushToken(); }).catch((err) => { - console.warn("Failed to get notification permission.", err); + console.error("Failed to get notification permission.", err); this.handleError(err.message, 'err'); this.setState({desktopAlerts: false, firebaseToken: null}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); @@ -1092,7 +1123,7 @@ class TinodeWeb extends React.Component { } } else if (this.state.firebaseToken && this.fbPush) { this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => { - console.warn("Unable to delete token.", err); + console.error("Unable to delete token.", err); }).finally(() => { LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); localStorage.removeItem('firebase-token'); @@ -1121,7 +1152,7 @@ class TinodeWeb extends React.Component { } }).catch((err) => { this.handleError(err.message, 'err'); - console.warn("Failed to retrieve firebase token", err); + console.error("Failed to retrieve firebase token", err); }); } @@ -1156,6 +1187,8 @@ class TinodeWeb extends React.Component { let path = ''; if (['security','support','general','notif'].includes(parsed.path[0])) { path = 'edit'; + } else if ('crop' == parsed.path[0]) { + path = 'general'; } else if ('blocked' == parsed.path[0]) { path = 'security'; } else if (this.state.myUserId) { @@ -1171,13 +1204,18 @@ class TinodeWeb extends React.Component { this.setState({errorText: '', errorLevel: null}); } - // Basic nagigator by hash value. No need to bind to this. + // Sidepanel navigator. No need to bind to 'this'. basicNavigator(hash) { HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash)); } + // Topic info navigator. No need to bind to 'this'. + infoNavigator(hash) { + HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash)); + } + // Request to start a topic, new or selected from search results, or "by ID". - handleStartTopicRequest(topicName, pub, priv, tags, isChannel) { + handleStartTopicRequest(topicName, newTopicParams, isChannel) { // Check if topic is indeed new. If not, launch it. if (topicName && this.tinode.isTopicCached(topicName)) { this.handleTopicSelected(topicName); @@ -1192,14 +1230,14 @@ class TinodeWeb extends React.Component { params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}}; } else { topicName = topicName || this.tinode.newGroupTopicName(isChannel); - params.desc = {public: pub, private: {comment: priv}}; - params.tags = tags; + params.desc = {public: newTopicParams.public, private: {comment: newTopicParams.private}}; + params.tags = newTopicParams.tags; } params._topicName = topicName; this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)}); } - // New topic was creted, here is the new topic name. + // New topic was created, here is the new topic name. handleNewTopicCreated(oldName, newName) { if (this.state.topicSelected == oldName && oldName != newName) { // If the current URl contains the old topic name, replace it with new. @@ -1211,21 +1249,49 @@ class TinodeWeb extends React.Component { } } - handleTopicUpdateRequest(topicName, pub, priv, permissions) { + handleTopicUpdateRequest(topicName, pub, priv, defacs) { + this.handleError(); + const topic = this.tinode.getTopic(topicName); if (topic) { const params = {}; + let attachments; if (pub) { + if (pub.photo) { + if (pub.photo.ref && pub.photo.ref != Tinode.DEL_CHAR) { + attachments = [pub.photo.ref]; + } else if (!pub.photo.data || pub.photo.data == Tinode.DEL_CHAR) { + pub.photo = Tinode.DEL_CHAR; + } + } params.public = pub; } - if (priv) { + + if (typeof priv == 'string') { params.private = (priv === Tinode.DEL_CHAR) ? Tinode.DEL_CHAR : {comment: priv}; } - if (permissions) { - params.defacs = permissions; + if (defacs) { + params.defacs = defacs; } - topic.setMeta({desc: params}).catch((err) => { + topic.setMeta({desc: params, attachments: attachments}).catch((err) => { + this.handleError(err.message, 'err'); + }); + } + } + + handleUnarchive(topicName) { + const topic = this.tinode.getTopic(topicName); + if (topic) { + topic.archive(false); + } + } + + handleUpdatePasswordRequest(password) { + this.handleError(); + + if (password) { + this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => { this.handleError(err.message, 'err'); }); } @@ -1248,7 +1314,7 @@ class TinodeWeb extends React.Component { } } - handleTagsUpdated(topicName, tags) { + handleTagsUpdateRequest(topicName, tags) { const topic = this.tinode.getTopic(topicName); if (topic) { topic.setMeta({tags: tags}).catch((err) => { @@ -1373,7 +1439,33 @@ class TinodeWeb extends React.Component { contextMenuClickAt: {x: params.x, y: params.y}, contextMenuParams: params, contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName), - contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect() + contextMenuBounds: this.selfRef.current.getBoundingClientRect() + }); + } + + handleShowForwardDialog(params) { + if (this.state.sidePanelSelected == 'newtpk') { + // If the Find panel is active, close it. + this.handleSidepanelCancel(); + } + const header = '➦ ' + params.userName; + const content = params.forwarded ? + Tinode.Drafty.forwardedContent(params.content) : + typeof params.content == 'string' ? + Tinode.Drafty.init(params.content) : params.content; + const preview = Tinode.Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, + undefined, params.forwarded != null); + const msg = Tinode.Drafty.append( + Tinode.Drafty.appendLineBreak( + Tinode.Drafty.mention(header, params.userFrom)), + content); + const msgPreview = Tinode.Drafty.quote(header, params.userFrom, preview); + const head = { + forwarded: params.topicName + ':' + params.seq + }; + this.setState({ + forwardDialogVisible: true, + forwardMessage: { head: head, msg: msg, preview: msgPreview } }); } @@ -1417,6 +1509,13 @@ class TinodeWeb extends React.Component { }); } + handleHideForwardDialog(keepForwardedMessage) { + this.setState({ + forwardDialogVisible: false, + forwardMessage: keepForwardedMessage ? this.state.forwardMessage : null + }); + } + handleContextMenuAction(action, promise, params) { if (action == 'topic_archive') { if (promise && params.topicName && params.topicName == this.state.topicSelected) { @@ -1424,6 +1523,8 @@ class TinodeWeb extends React.Component { this.handleTopicSelected(null); }); } + } else if (action == 'menu_item_forward') { + this.handleShowForwardDialog(params); } } @@ -1442,13 +1543,8 @@ class TinodeWeb extends React.Component { } handleShowInfoView() { - HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', true)); - this.setState({showInfoPanel: true}); - } - - handleHideInfoView() { - HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'info')); - this.setState({showInfoPanel: false}); + HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info')); + this.setState({infoPanel: 'info'}); } handleMemberUpdateRequest(topicName, added, removed) { @@ -1524,7 +1620,7 @@ class TinodeWeb extends React.Component { render() { return ( -
    +
    {this.state.contextMenuVisible ? + : + null + } - {this.state.showInfoPanel ? + {this.state.infoPanel ?
    - +
    ); diff --git a/src/widgets/avatar-crop.jsx b/src/widgets/avatar-crop.jsx new file mode 100644 index 00000000..f0710b2a --- /dev/null +++ b/src/widgets/avatar-crop.jsx @@ -0,0 +1,68 @@ +// Edit account parameters. +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Cropper from './cropper.jsx'; +import { imageCrop } from '../lib/blob-helpers.js'; + +export default class AvatarCrop extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + top: 0, + left: 0, + width: 0, + height: 0, + scale: 1 + }; + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + // Cut out in image coordinates and desired scale factor. + handleChange(left, top, width, height, scale) { + this.setState({ + left: left, + top: top, + width: width, + height: height, + scale: scale + }); + } + + handleSubmit() { + imageCrop(this.props.mime, this.props.avatar, + this.state.left, this.state.top, this.state.width, this.state.height, + this.state.scale) + .then(img => { + this.props.onSubmit(img.mime, img.blob, img.width, img.height); + }) + .catch(err => { + this.props.onError(err); + }); + } + + render() { + return ( +
    +
    + +
    +
    + {this.props.onCancel ? + + : null} + +
    +
    + ); + } +}; diff --git a/src/widgets/avatar-upload.jsx b/src/widgets/avatar-upload.jsx index f9452393..a073841f 100644 --- a/src/widgets/avatar-upload.jsx +++ b/src/widgets/avatar-upload.jsx @@ -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 (
    - {this.props.readOnly || !this.state.dataUrl ? + {this.props.readOnly || !this.state.source ? null : - {e.preventDefault(); this.props.onImageChanged(null);}}> + {e.preventDefault(); this.props.onImageUpdated();}}> clear } - {this.state.dataUrl ? - : + {this.state.source ? + : this.props.readOnly && this.props.uid ?
    @@ -66,11 +57,12 @@ export default class AvatarUpload extends React.Component {
    {AVATAR_SIZE}×{AVATAR_SIZE}
    } {this.props.readOnly ? null : } + accept="image/*" onChange={this.handleFileReceived} />} {this.props.readOnly ? null : } +
    ); } diff --git a/src/widgets/badge-list.jsx b/src/widgets/badge-list.jsx new file mode 100644 index 00000000..5870f37d --- /dev/null +++ b/src/widgets/badge-list.jsx @@ -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(
    {icon_mapping[b] || b} {comment}
    ); + }); + return <>{badges}; + } + return null; + } +}; + +export default injectIntl(BadgeList); diff --git a/src/widgets/chat-message.jsx b/src/widgets/chat-message.jsx index f11dcaed..220b79d7 100644 --- a/src/widgets/chat-message.jsx +++ b/src/widgets/chat-message.jsx @@ -1,15 +1,16 @@ // 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 { fullFormatter, quoteFormatter } from '../lib/formatters.js'; 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 +26,17 @@ 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); + + this.formatterContext = { + getFormatter: (tp) => { return tp == 'QQ' ? quoteFormatter : null; }, + formatMessage: props.intl.formatMessage.bind(props.intl), + viewportWidth: props.viewportWidth, + authorizeURL: props.tinode.authorizeURL.bind(props.tinode), + onImagePreview: this.handleImagePreview, + onFormButtonClick: this.handleFormButtonClick, + onQuoteClick: this.handleQuoteClick + }; } handleImagePreview(e) { @@ -59,9 +71,27 @@ export default class ChatMessage extends React.Component { handleContextClick(e) { e.preventDefault(); e.stopPropagation(); - const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : []; - this.props.showContextMenu({ seq: this.props.seq, content: this.props.content, - y: e.pageY, x: e.pageX }, menuItems); + const menuItems = []; + if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) { + menuItems.push('menu_item_send_retry'); + } + if (this.props.userIsWriter && + this.props.received > Tinode.MESSAGE_STATUS_FAILED && + this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) { + menuItems.push('menu_item_reply'); + } + menuItems.push('menu_item_forward'); + + this.props.showContextMenu({ + seq: this.props.seq, + content: this.props.content, + userFrom: this.props.userFrom, + userName: this.props.userName, + forwarded: this.props.forwarded, + y: e.pageY, + x: e.pageX, + pickReply: this.props.pickReply + }, menuItems); } handleProgress(ratio) { @@ -72,18 +102,27 @@ 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')); const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble'; const avatar = this.props.deleted ? null : (this.props.userAvatar || true); - const fullDisplay = (this.props.userFrom && this.props.response && !this.props.deleted && + const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted && (this.props.sequence == 'single' || this.props.sequence == 'last')); let content = this.props.content; const attachments = []; if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) { - Drafty.attachments(content, function(att, i) { + Drafty.attachments(content, (att, i) => { if (att.mime == 'application/json') { // Don't show json objects as attachments. // They are not meant for users. @@ -101,7 +140,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, fullFormatter, this.formatterContext); + content = React.createElement(React.Fragment, null, tree); } else if (this.props.deleted) { // Message represents a range of deleted messages. content = <>block @@ -109,19 +149,18 @@ export default class ChatMessage extends React.Component { defaultMessage="content deleted" description="Shown when messages are deleted" /> } else if (typeof content != 'string') { - content = <> - error_outline - - + content = <>warning_amber + } return ( -
  • - {this.props.userFrom && this.props.response ? +
  • + {this.props.isGroup && this.props.response ?
    {fullDisplay ? : @@ -152,10 +191,10 @@ export default class ChatMessage extends React.Component {
    {fullDisplay ?
    - { - (notFound) => {return this.props.userName || {notFound}} - } + {this.props.userName || + + }
    : null } @@ -165,86 +204,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) => ); -// 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 . - 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 = <>extension; - } - break; - } - return React.createElement(el, attr, values); - } else { - return values; - } -}; +export default ChatMessage; diff --git a/src/widgets/checkbox.jsx b/src/widgets/checkbox.jsx index 04f48f37..28d10c2f 100644 --- a/src/widgets/checkbox.jsx +++ b/src/widgets/checkbox.jsx @@ -15,9 +15,11 @@ export default class CheckBox extends React.PureComponent { render() { return ( this.props.onChange ? ( - this.props.checked ? + this.props.checked === true ? check_box : - check_box_outline_blank + this.props.checked === false ? + check_box_outline_blank : + indeterminate_check_box ) : ( this.props.checked ? check_box : diff --git a/src/widgets/chip-input.jsx b/src/widgets/chip-input.jsx index 48bb06f3..5e85d34b 100644 --- a/src/widgets/chip-input.jsx +++ b/src/widgets/chip-input.jsx @@ -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( 0); return (
    {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} />
    ); } diff --git a/src/widgets/chip.jsx b/src/widgets/chip.jsx index 8182120c..711a8298 100644 --- a/src/widgets/chip.jsx +++ b/src/widgets/chip.jsx @@ -23,6 +23,7 @@ export default class Chip extends React.PureComponent { :
    diff --git a/src/widgets/contact-badges.jsx b/src/widgets/contact-badges.jsx index 9b3cdf68..72a60308 100644 --- a/src/widgets/contact-badges.jsx +++ b/src/widgets/contact-badges.jsx @@ -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({icon_mapping[b.icon]}); - } else { - const style = 'badge' + (b.color ? ' ' + b.color : ''); - badges.push({b.name}); - } - }); - 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({icon_mapping[b.icon] || b.icon}); + } else { + badges.push({b.name}); + } + }); + return <>{badges}; } + return null; + } }; diff --git a/src/widgets/contact-list.jsx b/src/widgets/contact-list.jsx index fb551422..dd33218b 100644 --- a/src/widgets/contact-list.jsx +++ b/src/widgets/contact-list.jsx @@ -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'; @@ -77,22 +77,26 @@ class ContactList extends React.Component { const comment = Array.isArray(c.private) ? c.private.join(',') : (c.private ? c.private.comment : null); let preview; + let forwarded; let deliveryStatus; if (!this.props.showMode && c.latestMessage) { const msg = c.latestMessage(true); if (msg) { + forwarded = msg.head ? msg.head.forwarded : null; deliveryStatus = msg._status || c.msgStatus(msg, true); preview = typeof msg.content == 'string' ? msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) : - Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH); + Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH, undefined, forwarded != null); } } contactNodes.push( diff --git a/src/widgets/contact.jsx b/src/widgets/contact.jsx index 6f29eae2..ea8c733e 100644 --- a/src/widgets/contact.jsx +++ b/src/widgets/contact.jsx @@ -1,6 +1,6 @@ // A single topic or user. import React from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { FormattedMessage, injectIntl } from 'react-intl'; import ContactBadges from './contact-badges.jsx'; import LetterTile from './letter-tile.jsx'; @@ -9,26 +9,9 @@ import UnreadBadge from './unread-badge.jsx'; import { Drafty } from 'tinode-sdk'; +import { previewFormatter } from '../lib/formatters.js'; import { deliveryMarker } from '../lib/utils.js'; -const messages = defineMessages({ - drafty_image: { - id: 'drafty_image', - defaultMessage: 'Picture', - description: 'Comment for embedded images in drafty preview' - }, - drafty_form: { - id: 'drafty_form', - defaultMessage: 'Form: ', - description: 'Comment for form in drafty preview' - }, - drafty_attachment: { - id: 'drafty_attachment', - defaultMessage: 'Attachment', - description: 'Comment for attachment in drafty preview' - }, -}); - class Contact extends React.Component { constructor(props) { super(props); @@ -65,6 +48,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 +71,14 @@ 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, previewFormatter, + {formatMessage: this.props.intl.formatMessage})) : + <>warning_amber + + + ) : this.props.comment; const icon = deliveryMarker(this.props.received); @@ -90,6 +89,7 @@ class Contact extends React.Component {
  • @@ -101,11 +101,11 @@ class Contact extends React.Component {
    {title} {this.props.isChannel ? channel : null} - +
    {this.props.showMode ? : -
    {marker}{subtitle || '\u00A0'}
    } + this.props.small ? null :
    {marker}{subtitle || '\u00A0'}
    }
    {this.props.showContextMenu ? @@ -118,63 +118,4 @@ class Contact extends React.Component { } }; - -// Converts Drafty object into a one-line preview. -function draftyFormatter(style, data, values, key) { - let el = Drafty.tagName(style); - const attr = { key: key }; - if (el) { - const { formatMessage } = this.props.intl; - switch (style) { - case 'BR': - // Replace new line with a space. - el = React.Fragment; - values = [' ']; - break; - case 'HL': - // Make highlight less prominent in preview. - attr.className = 'highlight preview'; - break; - case 'LN': - // Disable links in previews. - el = 'span'; - break; - case 'IM': - // Replace image with '[icon] Image'. - el = React.Fragment; - values = [photo, formatMessage(messages.drafty_image)]; - break; - case 'BN': - el = 'span'; - attr.className = 'flat-button faux'; - break; - case 'FM': - el = React.Fragment; - values = [dashboard, - formatMessage(messages.drafty_form)].concat(values || []); - break; - case 'RW': - el = React.Fragment; - break; - case 'EX': - if (data && data.mime == 'application/json') { - // Ignore JSON attachments: they are form response payloads. - return null; - } - el = React.Fragment; - values = [attachment, formatMessage(messages.drafty_attachment)]; - break; - default: - if (el == '_UNKN') { - el = React.Fragment; - values = [extension]; - } - break; - } - return React.createElement(el, attr, values); - } else { - return values; - } -}; - export default injectIntl(Contact); diff --git a/src/widgets/context-menu.jsx b/src/widgets/context-menu.jsx index 03565fdd..33da13d9 100644 --- a/src/widgets/context-menu.jsx +++ b/src/widgets/context-menu.jsx @@ -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: { @@ -47,6 +45,16 @@ const messages = defineMessages({ defaultMessage: 'Unmute', description: 'Turn notifications on' }, + reply: { + id: 'menu_item_reply', + defaultMessage: 'Reply', + description: 'Reply to message' + }, + forward: { + id: 'menu_item_forward', + defaultMessage: 'Forward', + description: 'Forward message' + }, topic_delete: { id: 'menu_item_delete_topic', defaultMessage: 'Delete', @@ -109,6 +117,8 @@ class ContextMenu extends React.Component { constructor(props) { super(props); + this.selfRef = React.createRef(); + const {formatMessage} = props.intl; this.handlePageClick = this.handlePageClick.bind(this); @@ -171,6 +181,18 @@ class ContextMenu extends React.Component { return this.retryMessage(params, errorHandler); } }, + 'menu_item_reply': { + id: 'menu_item_reply', + title: formatMessage(messages.reply), + handler: (params, errorHandler) => { + return this.replyToMessage(params, errorHandler); + } + }, + 'menu_item_forward': { + id: 'menu_item_forward', + title: formatMessage(messages.forward), + handler: () => {} /* the action is taken directly in tinode-web */ + }, 'topic_unmute': { id: 'topic_unmute', title: formatMessage(messages.unmute), @@ -318,7 +340,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(); @@ -342,7 +364,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, @@ -411,9 +433,13 @@ class ContextMenu extends React.Component { return result; } + replyToMessage(params, errorHandler) { + params.pickReply(params.seq, params.content, params.forwarded, params.userFrom, params.userName, errorHandler); + } + render() { + const menu = []; let count = 0; - let menu = []; this.props.items.map((item) => { if (typeof item == 'string') { item = this.MenuItems[item]; @@ -445,7 +471,7 @@ class ContextMenu extends React.Component { }; return ( -
      +
        {menu}
      ); diff --git a/src/widgets/cropper.jsx b/src/widgets/cropper.jsx new file mode 100644 index 00000000..feabac58 --- /dev/null +++ b/src/widgets/cropper.jsx @@ -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 ( +
      +
      + +
      +
      +
      +
      + +
      +
      + ); + } +} diff --git a/src/widgets/doc-preview.jsx b/src/widgets/doc-preview.jsx index ca9a3af7..e09c95ac 100644 --- a/src/widgets/doc-preview.jsx +++ b/src/widgets/doc-preview.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import SendMessage from '../widgets/send-message.jsx'; -import { bytesToHumanSize } from '../lib/strformat.js'; +import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js'; // Get material icon name from mime type. function iconFromMime(mime) { @@ -31,12 +31,14 @@ export default class DocPreview extends React.PureComponent { return (
      - {this.props.content.filename} + {this.props.content.name} {e.preventDefault(); this.props.onClose();}}>close
      {iconFromMime(this.props.content.type)} +
      {shortenFileName(this.props.content.name, 24) || '-'}
      {this.props.content.type || 'application/octet-stream'}
      diff --git a/src/widgets/forward-dialog.jsx b/src/widgets/forward-dialog.jsx new file mode 100644 index 00000000..ee1ed1f8 --- /dev/null +++ b/src/widgets/forward-dialog.jsx @@ -0,0 +1,88 @@ +// Forward Menu: message forwarding popup/dropdown menu. +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Tinode from 'tinode-sdk'; + +import ContactList from './contact-list.jsx'; +import SearchContacts from './search-contacts.jsx'; + +export default class ForwardDialog extends React.Component { + constructor(props) { + super(props); + + this.state = { + query: null + }; + + this.handleEscapeKey = this.handleEscapeKey.bind(this); + this.handleClose = this.handleClose.bind(this); + + this.handleSearchContacts = this.handleSearchContacts.bind(this); + this.handleContactSelected = this.handleContactSelected.bind(this); + } + + componentDidMount() { + this.props.onInitFind(); + } + + handleEscapeKey(e) { + if (e.keyCode === 27) { + // Not forwarding the message. + this.props.hide(false); + } + } + + handleClose(e) { + e.preventDefault(); + this.props.hide(false); + } + + handleSearchContacts(query) { + this.setState({ query: Tinode.isNullValue(query) ? null : query }); + this.props.onSearchContacts(query); + } + + handleContactSelected(uid) { + this.props.onTopicSelected(uid); + this.props.hide(true); + } + + render() { + let contacts = this.state.query != null ? this.props.searchResults : this.props.contacts; + // Filter out contacts without a 'W' or 'J' permissions as well as the current topic. + contacts = contacts.filter((c) => { + return (c.name != this.props.topicSelected) && c.acs.isJoiner() && c.acs.isWriter(); + }); + + return ( +
      +
      +
      +
      + +
      + { + (search_placeholder) => + } + { + (not_found_placeholder) => + } +
      +
      + ); + } +} diff --git a/src/widgets/group-manager.jsx b/src/widgets/group-manager.jsx index 5f7906ed..64705022 100644 --- a/src/widgets/group-manager.jsx +++ b/src/widgets/group-manager.jsx @@ -165,6 +165,7 @@ class GroupManager extends React.Component {
      - +
      ); diff --git a/src/widgets/group-subs.jsx b/src/widgets/group-subs.jsx index e123b686..2ec40453 100644 --- a/src/widgets/group-subs.jsx +++ b/src/widgets/group-subs.jsx @@ -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(
      ); diff --git a/src/widgets/image-preview.jsx b/src/widgets/image-preview.jsx index 15b20405..fcec954d 100644 --- a/src/widgets/image-preview.jsx +++ b/src/widgets/image-preview.jsx @@ -4,7 +4,7 @@ import SendMessage from '../widgets/send-message.jsx'; import { REM_SIZE } from '../config.js'; import { fitImageSize } from '../lib/blob-helpers.js'; -import { bytesToHumanSize } from '../lib/strformat.js'; +import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js'; export default class ImagePreview extends React.PureComponent { constructor(props) { @@ -45,34 +45,35 @@ export default class ImagePreview extends React.PureComponent { size.maxWidth = '100%'; size.maxHeight = '100%'; - let filename = this.props.content.filename; // Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width. const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12); - if (filename.length > maxlength) { - filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2); - } + const fname = shortenFileName(this.props.content.name, maxlength) || '-'; + const width = this.props.content.width || '-'; const height = this.props.content.height || '-'; return (
      {!this.props.onSendMessage ? - + file_download : - {this.props.content.filename} + {fname} } {e.preventDefault(); this.props.onClose();}}>close
      this.assignWidth(node)}> - {this.props.content.filename} + {this.props.content.name}
      {this.props.onSendMessage ? : @@ -80,7 +81,7 @@ export default class ImagePreview extends React.PureComponent {
      -
      {filename ? filename : '-'}
      +
      {fname}
      { + 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 ( + {spanText} + ); } - return ( - this.state.active ? - (fieldType == 'password' ? - - : - - ) - : - - {spanText} - - ); + + 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); } }; diff --git a/src/widgets/lazy-image.jsx b/src/widgets/lazy-image.jsx new file mode 100644 index 00000000..40cb5d84 --- /dev/null +++ b/src/widgets/lazy-image.jsx @@ -0,0 +1,42 @@ +// Image with a placeholder which is replaced when the promise is resolved. +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +export default class LazyImage extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + src: 'img/placeholder.png', + style: Object.assign({padding: '4px'}, this.props.style), + className: this.props.className, + alt: this.props.alt, + onClick: this.props.onClick, + }; + } + + componentDidMount() { + // whenDone is a wrapper around an actual promise to be able to cancel it. + this.props.whenDone + .promise + .then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}})) + .catch(() => this.setState({src: 'img/broken_image.png'})); + } + + componentWillUnmount() { + this.props.whenDone.cancel(); + } + + componentDidUpdate(prevProps) { + if (prevProps.whenDone != this.props.whenDone) { + this.setState({src: 'img/placeholder.png', style: {...this.state.style, padding: '4px'}}); + this.props.whenDone + .then(data => this.setState({src: data.src, style: {...this.state.style, padding: 0}})) + .catch(() => this.setState({src: 'img/broken_image.png'})); + } + } + + render() { + return React.createElement('img', this.state); + } +}; diff --git a/src/widgets/letter-tile.jsx b/src/widgets/letter-tile.jsx index 672dc1f2..ec522246 100644 --- a/src/widgets/letter-tile.jsx +++ b/src/widgets/letter-tile.jsx @@ -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 { group : person; } } else if (this.props.avatar) { + const url = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar)); // If avatar image is invalid, show a placeholder. - avatar = avatar{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />; + avatar = avatar{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />; } else { avatar = null; } diff --git a/src/widgets/load-spinner.jsx b/src/widgets/load-spinner.jsx index 6d4aab82..c270e371 100644 --- a/src/widgets/load-spinner.jsx +++ b/src/widgets/load-spinner.jsx @@ -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 ? -
      : null); +
      : null); } } diff --git a/src/widgets/more-button.jsx b/src/widgets/more-button.jsx deleted file mode 100644 index 7e0529e3..00000000 --- a/src/widgets/more-button.jsx +++ /dev/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 (); - } -} diff --git a/src/widgets/new-topic-group.jsx b/src/widgets/new-topic-group.jsx index 37bcaefe..1dd14f1e 100644 --- a/src/widgets/new-topic-group.jsx +++ b/src/widgets/new-topic-group.jsx @@ -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 (
      -
      -
      +
      +
      + +
      +
      +
      +
      + + { + (placeholder) => }
      -
      -   +  
      - { (title) => }
      diff --git a/src/widgets/permissions-editor.jsx b/src/widgets/permissions-editor.jsx index 3acd6c47..901cfc75 100644 --- a/src/widgets/permissions-editor.jsx +++ b/src/widgets/permissions-editor.jsx @@ -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 (
      {this.props.userTitle ? -
      : null} + small={true} + avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} />
    : null} diff --git a/src/widgets/search-contacts.jsx b/src/widgets/search-contacts.jsx index 1739b1f9..eb0f9cc0 100644 --- a/src/widgets/search-contacts.jsx +++ b/src/widgets/search-contacts.jsx @@ -57,16 +57,14 @@ export default class SearchContacts extends React.PureComponent {
    search - { - (search_placeholder) => - } - - close - + {this.state.search ? + highlight_off + : +  }
    ); diff --git a/src/widgets/send-message.jsx b/src/widgets/send-message.jsx index bf268d50..d9ce55ca 100644 --- a/src/widgets/send-message.jsx +++ b/src/widgets/send-message.jsx @@ -5,6 +5,7 @@ import { Drafty } from 'tinode-sdk'; import { KEYPRESS_DELAY } from '../config.js'; import { filePasted } from '../lib/blob-helpers.js'; +import { quoteFormatter, replyFormatter } from '../lib/formatters.js'; const messages = defineMessages({ messaging_disabled: { @@ -50,12 +51,18 @@ 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); + + this.formatReply = this.formatReply.bind(this); } componentDidMount() { if (this.messageEditArea) { this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false); } + + this.setState({quote: this.formatReply()}); } componentWillUnmount() { @@ -64,10 +71,22 @@ class SendMessage extends React.PureComponent { } } - componentDidUpdate() { + componentDidUpdate(prevProps) { if (this.messageEditArea) { this.messageEditArea.focus(); } + + if (prevProps.reply != this.props.reply) { + this.setState({quote: this.formatReply()}); + } + } + + formatReply() { + return this.props.reply ? + Drafty.format(this.props.reply.content, replyFormatter, { + formatMessage: this.props.intl.formatMessage.bind(this.props.intl), + authorizeURL: this.props.tinode.authorizeURL.bind(this.props.tinode) + }) : null; } handlePasteEvent(e) { @@ -141,46 +160,67 @@ class SendMessage extends React.PureComponent { this.setState(newState); } + handleQuoteClick(e) { + e.preventDefault(); + e.stopPropagation(); + if (this.props.reply && this.props.onQuoteClick) { + const replyToSeq = this.props.reply.seq; + this.props.onQuoteClick(replyToSeq); + } + } + render() { - const {formatMessage} = this.props.intl; + const { formatMessage } = this.props.intl; const prompt = this.props.disabled ? formatMessage(messages.messaging_disabled) : (this.props.messagePrompt ? formatMessage(messages[this.props.messagePrompt]) : formatMessage(messages.type_new_message)); + + const quote = + (); + return ( -
    - {!this.props.disabled ? - <> - {this.props.onAttachFile ? - <> - {e.preventDefault(); this.attachImage.click();}} title="Add image"> - photo - - {e.preventDefault(); this.attachFile.click();}} title="Attach file"> - attach_file - - - : - null} - {this.props.noInput ? -
    : -