diff --git a/index.html b/index.html index b122f711..485eb426 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ - diff --git a/package.json b/package.json index 910b841e..39079722 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "0.15.8-rc1", "devDependencies": { "babel-cli": "^6.26.0", + "babel-plugin-react-intl": "^3.0.1", "babel-preset-env": "^1.7.0", "babel-preset-react": "^6.24.1", "cssnano": "^4.0.5", @@ -17,7 +18,7 @@ "build": "npm run vers && npm run build:babel && npm run build:prod && npm run build:dev && npm run build:css", "build:prod": "browserify ./src/index.js --standalone Webapp | uglifyjs --compress --mangle > ./umd/index.prod.js", "build:dev": "browserify ./src/index.js --standalone Webapp > ./umd/index.dev.js", - "build:babel": "babel ./src/webapp.jsx --presets react,env --out-file ./src/webapp.es5.js", + "build:babel": "babel ./src/webapp.jsx --out-file ./src/webapp.es5.js", "build:css": "postcss css/base.css > css/base.min.css", "vers": "echo \"{\\\"version\\\": \\\"`node -p -e \"require('./package.json').version\"`\\\"}\" > version.json" }, @@ -69,6 +70,7 @@ "firebase": "^5.5.5", "react": "^16.5.2", "react-dom": "^16.5.2", + "react-intl": "^2.7.1", "tinode-sdk": "^0.15.8-rc1" } } diff --git a/src/components/attachment.jsx b/src/components/attachment.jsx new file mode 100644 index 00000000..83de0a8c --- /dev/null +++ b/src/components/attachment.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import FileProgress from './file-progress.jsx'; + +export default class Attachment extends React.Component { + constructor(props) { + super(props); + + this.state = { + downloader: null, + progress: 0 + }; + + this.downloadFile = this.downloadFile.bind(this); + this.handleCancel = this.handleCancel.bind(this); + } + + downloadFile(url, filename, mimetype) { + var downloader = this.props.tinode.getLargeFileHelper(); + this.setState({downloader: downloader}); + downloader.download(url, filename, mimetype, (loaded) => { + this.setState({progress: loaded / this.props.size}); + }).then(() => { + this.setState({downloader: null, progress: 0}); + }).catch((err) => { + if (err) { + this.props.onError("Error downloading file: " + err.message, 'err'); + } + this.setState({downloader: null, progress: 0}); + }); + } + + handleCancel() { + if (this.props.uploader) { + this.props.onCancelUpload(); + } else if (this.state.downloader) { + this.state.downloader.cancel(); + } + } + + render() { + let filename = this.props.filename || 'file_attachment'; + if (filename.length > 36) { + filename = filename.substr(0, 16) + '...' + filename.substr(-16); + } + let size = this.props.size > 0 ? + ({bytesToHumanSize(this.props.size)}) : + null; + + // Detect if the download URL is relative or absolute. + // If the URL is relative use LargeFileHelper to attach authentication + // credentials to the request. + let url, helperFunc; + if (!this.props.uploader && !this.state.downloader && + !(/^(?:(?:[a-z]+:)?\/\/)/i.test(this.props.downloadUrl))) { + // Relative URL. Use download helper. + url = "javascript:;"; + helperFunc = (e) => { + this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype); + }; + } else { + url = this.props.downloadUrl; + helperFunc = null; + } + return ( +
+
insert_drive_file
+
+
{filename} {size}
+ {this.props.uploader || this.state.downloader ? + + : +
+ file_download save +
+ } +
+
+ ); + } +}; diff --git a/src/components/avatar-upload.jsx b/src/components/avatar-upload.jsx new file mode 100644 index 00000000..470b5059 --- /dev/null +++ b/src/components/avatar-upload.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import LetterTile from './letter-tile.jsx'; + +export default class AvatarUpload extends React.Component { + constructor(props) { + super(props); + + this.state = { + dataUrl: props.avatar + }; + + this.handleFileUpload = this.handleFileUpload.bind(this); + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (prevState.dataUrl != nextProps.avatar) { + return { + dataUrl: nextProps.avatar + }; + } + return null; + } + + handleFileUpload(e) { + imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true, + // Success + (base64bits, mime) => { + var du = makeImageUrl({data: base64bits, type: mime}); + this.setState({dataUrl: du}); + this.props.onImageChanged(du); + }, + // Failure + (err) => { + this.props.onError(err, 'err'); + }); + // Clear the value so the same file can be uploaded again. + e.target.value = ''; + } + + render() { + // Randomize id value in case more than one AvatarUpload is shown + // at the same time. + var randId = "file-input-avatar-" + (Math.random() + '').substr(2); + return ( +
+ {this.state.dataUrl ? + : + this.props.readOnly && this.props.uid ? +
+ +
+ : +
128×128
} + {this.props.readOnly ? null : + } + {this.props.readOnly ? null : + } +
+ ); + } +}; diff --git a/src/components/chat-message.jsx b/src/components/chat-message.jsx new file mode 100644 index 00000000..62902ec5 --- /dev/null +++ b/src/components/chat-message.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import {Drafty} from 'tinode-sdk' + +import LetterTile from './letter-tile.jsx' +import ReceivedMarker from './received-marker.jsx' + +/* Single message, sent or received. */ +export default class ChatMessage extends React.Component { + constructor(props) { + super(props); + + this.state = { + progress: 0 + }; + + if (props.uploader) { + props.uploader.onProgress = this.handleProgress.bind(this); + } + + this.handlePreviewImage = this.handlePreviewImage.bind(this); + this.handleContextClick = this.handleContextClick.bind(this); + this.handleCancelUpload = this.handleCancelUpload.bind(this); + } + + handlePreviewImage(e) { + e.preventDefault(); + this.props.onImagePreview({ + url: e.target.src, + filename: e.target.title, + width: e.target.dataset.width, + height: e.target.dataset.height, + size: e.target.dataset.size, + type: e.target.dataset.mime + }); + } + + handleContextClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX }); + } + + handleProgress(ratio) { + this.setState({progress: ratio}); + } + + handleCancelUpload() { + this.props.uploader.cancel(); + } + + render() { + var elementKey = 0; + + var formatter = function(style, data, values) { + elementKey += 1; + var el = Drafty.tagName(style); + if (el) { + var attr = Drafty.attrValue(style, data) || {}; + attr.key = elementKey; + if (style == 'IM') { + // Additional processing for images + var dim = fitImageSize(data.width, data.height, + Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false); + attr.className = 'inline-image'; + attr.style = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } : null; + attr.onClick = this.handlePreviewImage; + } + return React.createElement(el, attr, values); + } else { + return values; + } + }; + + var sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'); + var bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? + 'bubble tip' : 'bubble'; + var avatar = this.props.userAvatar || true; + var fullDisplay = (this.props.userFrom && this.props.response && + (this.props.sequence == 'single' || this.props.sequence == 'last')); + + var content = this.props.content; + var attachments = []; + if (this.props.mimeType == Drafty.getContentType()) { + Drafty.attachments(content, function(att, i) { + attachments.push(); + }, this); + content = React.createElement('span', null, Drafty.format(content, formatter, this)); + } else if (typeof content != 'string') { + content = error_outline invalid content + } + + return ( +
  • + {this.props.userFrom && this.props.response ? +
    + {fullDisplay ? + : + null} +
    : + null} +
    +
    +
    + {content} + {attachments} + +
    + + + expand_more + + +
    + {fullDisplay ?
    {this.props.userName}
    : null} +
    +
  • + ); + } +}; diff --git a/src/components/checkbox.jsx b/src/components/checkbox.jsx new file mode 100644 index 00000000..04f48f37 --- /dev/null +++ b/src/components/checkbox.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +/* BEGIN CheckBox: styled checkbox */ +export default class CheckBox extends React.PureComponent { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + } + + handleChange() { + this.props.onChange(this.props.name, !this.props.checked); + } + + render() { + return ( + this.props.onChange ? ( + this.props.checked ? + check_box : + check_box_outline_blank + ) : ( + this.props.checked ? + check_box : + check_box_outline_blank + ) + ); + } +} +/* END CheckBox */ diff --git a/src/components/chip-input.jsx b/src/components/chip-input.jsx new file mode 100644 index 00000000..9463ef29 --- /dev/null +++ b/src/components/chip-input.jsx @@ -0,0 +1,138 @@ +import React from 'react'; + +/* BEGIN ChipInput: group membership widget */ +export default class ChipInput extends React.Component { + constructor(props) { + super(props); + + this.state = { + placeholder: props.chips ? '' : props.prompt, + sortedChips: ChipInput.sortChips(props.chips, props.required), + chipIndex: ChipInput.indexChips(props.chips), + input: '', + focused: false + }; + + this.handleTextInput = this.handleTextInput.bind(this); + this.removeChipAt = this.removeChipAt.bind(this); + this.handleChipCancel = this.handleChipCancel.bind(this); + this.handleFocusGained = this.handleFocusGained.bind(this); + this.handleFocusLost = this.handleFocusLost.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + } + + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState({ + sortedChips: ChipInput.sortChips(nextProps.chips, nextProps.required), + chipIndex: ChipInput.indexChips(nextProps.chips) + }); + if (nextProps.chips.length > this.props.chips.length) { + // Chip added: clear input. + this.setState({input: ''}); + } + } + + // Map chip index to user name + static indexChips(chips) { + var index = {}; + var count = 0; + chips.map(function(item) { + index[item.user] = count; + count ++; + }); + return index; + } + + // Have non-removable chips appear before all other chips. + static sortChips(chips, keep) { + var required = []; + var normal = []; + chips.map(function(item) { + if (item.user === keep) { + required.push(item); + } else { + normal.push(item); + } + }); + return required.concat(normal); + } + + handleTextInput(e) { + this.setState({input: e.target.value}); + if (this.props.filterFunc) { + this.props.filterFunc(e.target.value); + } + } + + removeChipAt(idx) { + var removed = this.state.sortedChips[idx]; + this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]); + } + + handleChipCancel(item, idx) { + this.removeChipAt(idx); + } + + handleFocusGained() { + this.setState({focused: true}); + } + + handleFocusLost() { + this.setState({focused: false}); + if (this.props.onFocusLost) { + this.props.onFocusLost(this.state.input); + } + } + + handleKeyDown(e) { + if (e.key === 'Backspace') { + if (this.state.input.length == 0 && this.state.sortedChips.length > 0) { + var at = this.state.sortedChips.length - 1; + if (this.state.sortedChips[at].user !== this.props.required) { + this.removeChipAt(at); + } + } + } else if (e.key === 'Enter') { + if (this.props.onEnter) { + this.props.onEnter(this.state.input); + } + } else if (e.key === 'Escape') { + if (this.props.onCancel) { + this.props.onCancel(); + } + } + } + + render() { + var chips = []; + var count = 0; + this.state.sortedChips.map((item) => { + chips.push( + + ); + count++; + }); + var className = "chip-input" + (this.state.focused ? ' focused' : ''); + return ( +
    + {chips} + +
    + ); + } +}; diff --git a/src/components/chip.jsx b/src/components/chip.jsx new file mode 100644 index 00000000..8cb63899 --- /dev/null +++ b/src/components/chip.jsx @@ -0,0 +1,34 @@ +import React from 'react'; + +export default class Chip extends React.PureComponent { + constructor(props) { + super(props); + + this.handleCancel = this.handleCancel.bind(this); + } + + handleCancel() { + this.props.onCancel(this.props.topic, this.props.index); + } + + render() { + var title = this.props.title || this.props.topic; + return ( +
    + {this.props.noAvatar ? + : +
    + +
    + } + {title} + {this.props.onCancel && !this.props.required ? + × + : } +
    + ); + } +}; diff --git a/src/components/contact-badges.jsx b/src/components/contact-badges.jsx new file mode 100644 index 00000000..733c4957 --- /dev/null +++ b/src/components/contact-badges.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +/* Contact's labels: [you], [muted], [blocked], etc */ +// FIXME: this class is unused. +export default class ContactBadges_UNUSED_REMOVE extends React.PureComponent { + render() { + let badges = null; + if (this.props.badges && this.props.badges.length > 0) { + badges = []; + this.props.badges.map(function(b) { + var style = 'badge' + (b.color ? ' ' + b.color : ''); + // Badge names are expected to be unique, so using the name as the key. + badges.push({b.name}); + }); + } + return badges; + } +}; diff --git a/src/components/contact-list.jsx b/src/components/contact-list.jsx new file mode 100644 index 00000000..1c7ea4de --- /dev/null +++ b/src/components/contact-list.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import Contact from './contact.jsx'; + +/* ContactList: component for showing a list of contacts, + * such as a list of group members in a group chat */ +export default class ContactList extends React.Component { + render() { + var contactNodes = []; + var showCheckmark = Array.isArray(this.props.topicSelected); + if (this.props.contacts && this.props.contacts.length > 0) { + this.props.contacts.map((c) => { + var key = c.topic ? c.topic : c.user; + // If filter function is provided, filter out the items + // which don't satisfy the condition. + if (this.props.filterFunc && this.props.filter) { + var content = [key]; + if (c.private && c.private.comment) { + content.push(('' + c.private.comment).toLowerCase()); + } + if (c.public && c.public.fn) { + content.push(('' + c.public.fn).toLowerCase()); + } + if (!this.props.filterFunc(this.props.filter, content)) { + return; + } + } + + var selected = showCheckmark ? + (this.props.topicSelected.indexOf(key) > -1) : + (this.props.topicSelected === key); + var badges = []; + if (this.props.showMode) { + if (key === this.props.myUserId) { + badges.push({name: "you", color: 'green'}); + } + if (c.acs && c.acs.isOwner()) { + badges.push({name: "owner"}); + } + } + var comment = Array.isArray(c.private) ? + c.private.join(', ') : (c.private ? c.private.comment : null); + + contactNodes.push( + + ); + }, this); + } + + return ( +
    + {contactNodes.length > 0 ? +
      + {contactNodes} +
    + : +
    + {this.props.emptyListMessage} +
    } +
    + ); + } +}; +/* END ContactList */ diff --git a/src/components/contact.jsx b/src/components/contact.jsx new file mode 100644 index 00000000..10e68fb4 --- /dev/null +++ b/src/components/contact.jsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import LetterTile from './letter-tile.jsx'; +import UnreadBadge from './unread-badge.jsx'; + +/* A single topic or user */ + +export default class Contact extends React.Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + this.handleContextClick = this.handleContextClick.bind(this); + } + + handleClick(e) { + e.preventDefault(); + e.stopPropagation(); + if (this.props.onSelected) { + this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs); + } + } + + handleContextClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX }); + } + + render() { + var title = this.props.title; + if (!title) { + title = unknown; + } else if (title.length > 30) { + // FIXME: this is wrong for RTL languages. + title = title.substring(0, 28) + '...'; + } + var online = this.props.now ? 'online' : 'offline'; + var avatar = this.props.avatar ? this.props.avatar : true; + var badges = []; + if (this.props.badges && this.props.badges.length > 0) { + var count = 0; + this.props.badges.map(function(b) { + var style = 'badge' + (b.color ? ' ' + b.color : ''); + badges.push({b.name}); + count ++; + }); + } + if (this.props.showMode && this.props.acs) { + badges.push({this.props.acs.getMode()}); + } + + return ( +
  • +
    + + {this.props.showOnline ? : + (this.props.showCheckmark && this.props.selected ? + check_circle + : null)} +
    +
    +
    {title} + {this.props.unread > 0 ? : null} +
    + {this.props.comment ?
    {this.props.comment}
    : null} + {badges} +
    + {this.props.showContextMenu ? + + + expand_more + + : null} +
  • + ); + } +}; diff --git a/src/components/contacts-view.jsx b/src/components/contacts-view.jsx new file mode 100644 index 00000000..33a37242 --- /dev/null +++ b/src/components/contacts-view.jsx @@ -0,0 +1,42 @@ +import React from 'react'; + +/* ContactsView holds all contacts-related stuff */ + +export default class ContactsView extends React.Component { + constructor(props) { + super(props); + + this.state = ContactsView.getDerivedStateFromProps(props, {}); + } + + static getDerivedStateFromProps(nextProps, prevState) { + let contacts = []; + let unreadThreads = 0; + nextProps.chatList.map((c) => { + unreadThreads += c.unread > 0 ? 1 : 0; + contacts.push(c); + }); + contacts.sort(function(a, b){ + return (b.touched || 0) - (a.touched || 0); + }); + + updateFavicon(unreadThreads); + + return {contactList: contacts}; + } + + render() { + return ( + You have no chats
    ¯\_(ツ)_/¯} + topicSelected={this.props.topicSelected} + myUserId={this.props.myUserId} + showOnline={true} + showUnread={true} + onTopicSelected={this.props.onTopicSelected} + showContextMenu={this.props.showContextMenu} /> + ); + } +}; diff --git a/src/components/context-menu.jsx b/src/components/context-menu.jsx new file mode 100644 index 00000000..31485d05 --- /dev/null +++ b/src/components/context-menu.jsx @@ -0,0 +1,192 @@ +import React from 'react'; + +/* Context Menu: popup/dropdown menu */ + +export default class ContextMenu extends React.Component { + constructor(props) { + super(props); + + this.handlePageClick = this.handlePageClick.bind(this); + this.handleEscapeKey = this.handleEscapeKey.bind(this); + this.handleClick = this.handleClick.bind(this); + + // Preconfigured menu items. + this.MenuItems = { + 'topic_info': {title: "Info", handler: null}, + + 'messages_clear': {title: "Clear messages", handler: (params, errorHandler) => { + this.deleteMessages(true, false, params, errorHandler); + }}, + 'messages_clear_hard': {title: "Clear for All", handler: (params, errorHandler) => { + this.deleteMessages(true, true, params, errorHandler); + }}, + 'message_delete': {title: "Delete", handler: (params, errorHandler) => { + this.deleteMessages(false, false, params, errorHandler); + }}, + 'message_delete_hard': {title: "Delete for All", handler: (params, errorHandler) => { + this.deleteMessages(false, true, params, errorHandler); + }}, + 'topic_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')}, + 'topic_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')}, + 'topic_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')}, + 'topic_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')}, + 'topic_delete': {title: "Delete", handler: (params, errorHandler) => { + let topic = this.props.tinode.getTopic(params.topicName); + if (!topic) { + console.log("Topic not found: ", params.topicName); + return; + } + topic.delTopic().catch((err) => { + if (errorHandler) { + errorHandler(err.message, 'err'); + } + }); + }}, + + 'permissions': {title: "Edit permissions", handler: null}, + 'member_delete': {title: "Remove", handler: (params, errorHandler) => { + let topic = this.props.tinode.getTopic(params.topicName); + if (!topic || !params.user) { + console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'"); + return; + } + topic.delSubscription(params.user).catch((err) => { + if (errorHandler) { + errorHandler(err.message, 'err'); + } + }); + }}, + 'member_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')}, + 'member_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')}, + 'member_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')}, + 'member_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')}, + }; + } + + componentDidMount() { + document.addEventListener('mousedown', this.handlePageClick, false); + document.addEventListener('keyup', this.handleEscapeKey, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handlePageClick, false); + document.removeEventListener('keyup', this.handleEscapeKey, false); + } + + handlePageClick(e) { + if (ReactDOM.findDOMNode(this).contains(e.target)) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.props.hide(); + } + + handleEscapeKey(e) { + if (e.keyCode === 27) { + this.props.hide(); + } + } + + handleClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.hide(); + let item = this.props.items[e.currentTarget.dataset.id]; + if (typeof item == 'string') { + item = this.MenuItems[item]; + } + item.handler(this.props.params, this.props.onError); + } + + // Menu Actions + + deleteMessages(all, hard, params, errorHandler) { + var topic = this.props.tinode.getTopic(params.topicName); + if (!topic) { + console.log("Topic not found: ", params.topicName); + return; + } + // We don't know if the message is still pending (e.g. attachment is being uploaded), + // so try cancelling first. No harm if we can't cancel. + if (topic.cancelSend(params.seq)) { + return Promise.resolve(); + } + // Can't cancel. Delete instead. + var promise = all ? + topic.delMessagesAll(hard) : + topic.delMessagesList([params.seq], hard); + promise.catch((err) => { + if (errorHandler) { + errorHandler(err.message, 'err'); + } + }); + } + + // Function is used by context menu to set permissions. + topicPermissionSetter(mode, params, errorHandler) { + var topic = this.props.tinode.getTopic(params.topicName); + if (!topic) { + console.log("Topic not found", params.topicName); + return; + } + + var am, user; + if (params.user) { + user = topic.subscriber(params.user); + if (!user) { + console.log("Subscriber not found", params.topicName + "[" + params.user + "]"); + return; + } + am = user.acs.updateGiven(mode).getGiven(); + } else { + am = topic.getAccessMode().updateWant(mode).getWant(); + } + + topic.setMeta({sub: {user: params.user, mode: am}}).catch((err) => { + if (errorHandler) { + errorHandler(err.message, 'err'); + } + }); + } + + render() { + let count = 0; + let menu = []; + this.props.items.map((item) => { + if (typeof item == 'string') { + item = this.MenuItems[item]; + } + if (item && item.title) { + menu.push( + item.title == '-' ? +
  • + : +
  • {item.title}
  • + ); + } + count++; + }); + + // Ensure that menu is inside the app-container. + var hSize = 12 * REM_SIZE; + var vSize = REM_SIZE * (0.7 + menu.length * 2.5); + var left = (this.props.bounds.right - this.props.clickAt.x < hSize) ? + (this.props.clickAt.x - this.props.bounds.left - hSize) : + (this.props.clickAt.x - this.props.bounds.left); + var top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ? + (this.props.clickAt.y - this.props.bounds.top - vSize) : + (this.props.clickAt.y - this.props.bounds.top); + + var position = { + left: left + 'px', + top: top + 'px' + }; + + return ( +
      + {menu} +
    + ); + } +} diff --git a/src/components/create-account-view.jsx b/src/components/create-account-view.jsx new file mode 100644 index 00000000..adb1b837 --- /dev/null +++ b/src/components/create-account-view.jsx @@ -0,0 +1,102 @@ +import React from 'react'; + +/* Account registration */ + +export default class CreateAccountView extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + login: '', + password: '', + email: '', + fn: '', // full/formatted name + imageDataUrl: null, + errorCleared: false, + saveToken: localStorage.getObject('keep-logged-in') + }; + + this.handleLoginChange = this.handleLoginChange.bind(this); + this.handlePasswordChange = this.handlePasswordChange.bind(this); + this.handleEmailChange = this.handleEmailChange.bind(this); + this.handleFnChange = this.handleFnChange.bind(this); + this.handleImageChanged = this.handleImageChanged.bind(this); + this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleLoginChange(e) { + this.setState({login: e.target.value}); + } + + handlePasswordChange(password) { + this.setState({password: password}); + } + + handleEmailChange(e) { + this.setState({email: e.target.value}) + } + + handleFnChange(e) { + this.setState({fn: e.target.value}); + } + + handleImageChanged(img) { + this.setState({imageDataUrl: img}); + } + + handleToggleSaveToken() { + localStorage.setObject('keep-logged-in', !this.state.saveToken); + this.setState({saveToken: !this.state.saveToken}); + } + + handleSubmit(e) { + e.preventDefault(); + // TODO: check email for validity + this.setState({errorCleared: false}); + this.props.onCreateAccount( + this.state.login.trim(), + this.state.password.trim(), + vcard(this.state.fn, this.state.imageDataUrl), + {'meth': 'email', 'val': this.state.email}); + } + + render() { + var submitClasses = 'blue'; + if (this.props.disabled) { + submitClasses += ' disabled'; + } + return ( +
    +
    +
    + + +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    + +
    +
    + ); + } +}; diff --git a/src/components/edit-account-view.jsx b/src/components/edit-account-view.jsx new file mode 100644 index 00000000..419dd31c --- /dev/null +++ b/src/components/edit-account-view.jsx @@ -0,0 +1,192 @@ +import React from 'react'; + +import AvatarUpload from './avatar-upload.jsx'; +import CheckBox from './checkbox.jsx'; +import PermissionsEditor from './permissions-editor.jsx'; +import TagManager from './tag-manager.jsx'; + +/* Edit account parameters */ + +export default class EditAccountView extends React.Component { + constructor(props) { + super(props); + + let me = this.props.tinode.getMeTopic(); + let defacs = me.getDefaultAccess(); + let fnd = this.props.tinode.getFndTopic(); + this.state = { + fullName: me.public ? me.public.fn : undefined, + avatar: makeImageUrl(me.public ? me.public.photo : null), + auth: defacs ? defacs.auth : null, + anon: defacs ? defacs.anon : null, + tags: fnd.tags(), + showPermissionEditorFor: undefined, + previousOnTags: fnd.onTagsUpdated + }; + + this.tnNewTags = this.tnNewTags.bind(this); + this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this); + this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this); + this.handleImageChanged = this.handleImageChanged.bind(this); + this.handleCheckboxClick = this.handleCheckboxClick.bind(this); + this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this); + this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this); + this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this); + this.handleTagsUpdated = this.handleTagsUpdated.bind(this); + } + + componentDidMount() { + let fnd = this.props.tinode.getFndTopic(); + fnd.onTagsUpdated = this.tnNewTags; + if (!fnd.isSubscribed()) { + fnd.subscribe(fnd.startMetaQuery().withLaterDesc().withTags().build()).catch((err) => { + this.props.onError(err.message, 'err'); + }); + } + } + + componentWillUnmount() { + var fnd = this.props.tinode.getFndTopic(); + fnd.onTagsUpdated = this.state.previousOnTags; + } + + tnNewTags(tags) { + this.setState({tags: tags}); + } + + handleFullNameUpdate(fn) { + this.setState({fullName: fn}); + this.props.onUpdateAccount(undefined, vcard(fn, this.state.avatar)); + } + + handlePasswordUpdate(pwd) { + this.setState({password: pwd}); + this.props.onUpdateAccount(pwd); + } + + handleImageChanged(img) { + this.setState({avatar: img}); + this.props.onUpdateAccount(undefined, vcard(this.state.fullName, img)); + } + + handleCheckboxClick(what, checked) { + if (what == 'sound') { + this.props.onToggleMessageSounds(checked); + } else if (what == 'alert') { + this.props.onTogglePushNotifications(checked); + } + } + + handleLaunchPermissionsEditor(which) { + this.setState({ + showPermissionEditorFor: which, + editedPermissions: this.state[which] + }); + } + + handleHidePermissionsEditor() { + this.setState({showPermissionEditorFor: undefined}); + } + + handlePermissionsChanged(perm) { + let defacs = {}; + defacs[this.state.showPermissionEditorFor] = perm; + this.props.onUpdateAccount(undefined, undefined, defacs); + + let newState = {showPermissionEditorFor: undefined}; + newState[this.state.showPermissionEditorFor] = perm; + this.setState(newState); + } + + handleTagsUpdated(tags) { + // Check if tags have actually changed. + if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) { + return; + } + this.props.onUpdateTags(tags); + } + + render() { + var tags = []; + this.state.tags.map(function(tag) { + tags.push({tag}); + }); + if (tags.length == 0) { + tags = No tags defined. Add some.; + } + return ( + {this.state.showPermissionEditorFor ? + + : +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + {this.props.myUserId} +
    +
    + +
    +
    +
    Auth: {this.state.auth}
    +
    Anon: {this.state.anon}
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + + } + ); + } +}; diff --git a/src/components/error-panel.jsx b/src/components/error-panel.jsx new file mode 100644 index 00000000..3ff11117 --- /dev/null +++ b/src/components/error-panel.jsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import MenuCancel from './menu-cancel.jsx'; + +export default class ErrorPanel extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + show: false, + }; + + this.hide = this.hide.bind(this); + } + + static getDerivedStateFromProps(nextProps, prevState) { + return { + show: !(!nextProps.level) + }; + } + + hide() { + this.setState({show: false}); + if (this.props.onClearError) { + this.props.onClearError(); + } + } + + render() { + var icon = this.props.level == 'err' ? 'error': 'warning'; + return ( +
    +
    {icon}
    + {this.props.text} +
    +
    + ); + } +}; diff --git a/src/components/file-progress.jsx b/src/components/file-progress.jsx new file mode 100644 index 00000000..a2f32b9a --- /dev/null +++ b/src/components/file-progress.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +/* File uload/download progress indicator with a cancel inside */ + +export default class FileProgress extends React.PureComponent { + render() { + return ( +
    +
    + {this.props.progress < 0.999 ? + close cancel + : + finishing... + } +
    + ); + } +} diff --git a/src/components/group-manager.jsx b/src/components/group-manager.jsx new file mode 100644 index 00000000..b5f5ddde --- /dev/null +++ b/src/components/group-manager.jsx @@ -0,0 +1,161 @@ +import React from 'react'; + +/* GroupMembers: control for managing a list of group members */ +export default class GroupManager extends React.Component { + constructor(props) { + super(props); + + this.state = { + members: props.members, + index: GroupManager.indexMembers(props.members), + contactFilter: '', + noContactsMessage: "You have no contacts :-(", + selectedContacts: GroupManager.selectedContacts(props.members) + }; + + this.handleContactSelected = this.handleContactSelected.bind(this); + this.handleMemberRemoved = this.handleMemberRemoved.bind(this); + this.handleContactFilter = this.handleContactFilter.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + } + + static indexMembers(members) { + var index = {}; + members.map(function(m) { + index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed + }); + return index; + } + + static selectedContacts(members) { + var sel = []; + members.map(function(m) { + sel.push(m.user); + }); + return sel; + } + + handleContactSelected(userId, index) { + var status = this.state.index[userId]; + if (status) { + if (status.present) { + // Prevent duplicate members + return; + } + status.delta += 1; + status.present = true; + } else { + status = {delta: 1, present: true}; + } + + var m = this.state.members.slice(); + m.push(this.props.contacts[index]); + + var sel = GroupManager.selectedContacts(m); + + var i = this.state.index; + i[userId] = status; + + this.setState({members: m, index: i, selectedContacts: sel}); + } + + handleMemberRemoved(userId, index) { + var status = this.state.index[userId]; + if (!status || !status.present) { + return; + } + status.present = false; + status.delta -= 1; + + var m = this.state.members.slice(); + m.splice(index, 1); + + var sel = GroupManager.selectedContacts(m); + + var i = this.state.index; + i[userId] = status; + + this.setState({members: m, index: i, selectedContacts: sel}); + } + + handleContactFilter(val) { + var msg = !val ? + "You have no contacts :-(" : + "No contacts match '" + val + "'"; + + this.setState({contactFilter: val, noContactsMessage: msg}); + } + + static doContactFiltering(filter, values) { + if (filter) { + for (var i=0; i= 0) { + return true; + } + } + return false; + } + return true; + } + + handleSubmit() { + var instance = this; + var members = []; + var added = []; + var removed = []; + + var keys = Object.keys(this.state.index); + keys.map(function(k) { + if (instance.state.index[k].present) { + members.push(k); + } + + if (instance.state.index[k].delta > 0) { + added.push(k); + } else if (instance.state.index[k].delta < 0) { + removed.push(k); + } + }); + this.props.onSubmit(members, added, removed); + } + + handleCancel() { + this.props.onCancel(); + } + + render() { + return ( +
    +
    + +
    +
    + +
    +
    + +
    + +
    + + +
    +
    + ); + } +}; diff --git a/src/components/group-subs.jsx b/src/components/group-subs.jsx new file mode 100644 index 00000000..bc16ddda --- /dev/null +++ b/src/components/group-subs.jsx @@ -0,0 +1,35 @@ +import React from 'react'; + +/* GroupSubs: a list of group subscribers currently online */ +export default class GroupSubs extends React.Component { + constructor(props) { + super(props); + + this.state = { + onlineSubs: props.subscribers || [] + }; + } + + static getDerivedStateFromProps(nextProps, prevState) { + return { + onlineSubs: nextProps.subscribers ? nextProps.subscribers : [] + }; + } + + render() { + var usersOnline = []; + this.state.onlineSubs.map((sub) => { + usersOnline.push( +
    + +
    + ); + }); + return ( +
    {usersOnline}
    + ); + } +}; diff --git a/src/components/host-selector.jsx b/src/components/host-selector.jsx new file mode 100644 index 00000000..29a4d7e0 --- /dev/null +++ b/src/components/host-selector.jsx @@ -0,0 +1,48 @@ +import React from 'react'; + +/* Combobox for selecting host name */ + +export default class HostSelector extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + hostName: props.serverAddress, + changed: false + }; + + this.handleHostNameChange = this.handleHostNameChange.bind(this); + this.handleEditingFinished = this.handleEditingFinished.bind(this); + } + + handleHostNameChange(e) { + this.setState({hostName: e.target.value, changed: true}); + } + + handleEditingFinished() { + if (this.state.changed) { + this.setState({changed: false}); + this.props.onServerAddressChange(this.state.hostName.trim()); + } + } + + render() { + var hostOptions = []; + for (var key in KNOWN_HOSTS) { + var item = KNOWN_HOSTS[key]; + hostOptions.push( +