From 99dfb0afeba0e8ffd43992efde3dbdd0b42c1294 Mon Sep 17 00:00:00 2001 From: or-else Date: Wed, 24 Oct 2018 18:04:16 +0300 Subject: [PATCH] sort components into widgets and views --- src/lib/utils.js | 415 +++++++++++++++++ src/{components => views}/contacts-view.jsx | 2 +- .../create-account-view.jsx | 6 +- .../edit-account-view.jsx | 8 +- src/{components => views}/info-view.jsx | 17 +- src/{components => views}/login-view.jsx | 4 +- src/{components => views}/logo-view.jsx | 0 src/{components => views}/messages-view.jsx | 14 +- src/{components => views}/new-topic-view.jsx | 8 +- .../password-reset-view.jsx | 2 +- src/{components => views}/settings-view.jsx | 2 +- src/{components => views}/sidepanel-view.jsx | 5 +- src/{components => views}/tinode-web.jsx | 6 +- src/{components => views}/validation-view.jsx | 0 src/webapp.jsx | 418 ------------------ src/{components => widgets}/attachment.jsx | 0 src/{components => widgets}/avatar-upload.jsx | 0 src/{components => widgets}/chat-message.jsx | 0 src/{components => widgets}/checkbox.jsx | 0 src/{components => widgets}/chip-input.jsx | 0 src/{components => widgets}/chip.jsx | 0 .../contact-badges.jsx | 0 src/{components => widgets}/contact-list.jsx | 0 src/{components => widgets}/contact.jsx | 0 src/{components => widgets}/context-menu.jsx | 0 src/{components => widgets}/error-panel.jsx | 0 src/{components => widgets}/file-progress.jsx | 0 src/{components => widgets}/group-manager.jsx | 0 src/{components => widgets}/group-subs.jsx | 0 src/{components => widgets}/host-selector.jsx | 0 src/{components => widgets}/image-preview.jsx | 0 src/{components => widgets}/in-place-edit.jsx | 0 src/{components => widgets}/letter-tile.jsx | 0 src/{components => widgets}/load-spinner.jsx | 0 src/{components => widgets}/menu-cancel.jsx | 0 src/{components => widgets}/menu-contacts.jsx | 0 src/{components => widgets}/menu-start.jsx | 0 src/{components => widgets}/more-button.jsx | 0 .../new-topic-by-id.jsx | 0 .../new-topic-group.jsx | 0 .../permissions-editor.jsx | 0 .../received-marker.jsx | 0 .../search-contacts.jsx | 0 src/{components => widgets}/send-message.jsx | 0 src/{components => widgets}/side-navbar.jsx | 0 src/{components => widgets}/tag-manager.jsx | 0 src/{components => widgets}/unread-badge.jsx | 0 .../visible-password.jsx | 0 48 files changed, 454 insertions(+), 453 deletions(-) create mode 100644 src/lib/utils.js rename src/{components => views}/contacts-view.jsx (95%) rename src/{components => views}/create-account-view.jsx (95%) rename src/{components => views}/edit-account-view.jsx (96%) rename src/{components => views}/info-view.jsx (97%) rename src/{components => views}/login-view.jsx (96%) rename src/{components => views}/logo-view.jsx (100%) rename src/{components => views}/messages-view.jsx (97%) rename src/{components => views}/new-topic-view.jsx (92%) rename src/{components => views}/password-reset-view.jsx (96%) rename src/{components => views}/settings-view.jsx (97%) rename src/{components => views}/sidepanel-view.jsx (97%) rename src/{components => views}/tinode-web.jsx (99%) rename src/{components => views}/validation-view.jsx (100%) rename src/{components => widgets}/attachment.jsx (100%) rename src/{components => widgets}/avatar-upload.jsx (100%) rename src/{components => widgets}/chat-message.jsx (100%) rename src/{components => widgets}/checkbox.jsx (100%) rename src/{components => widgets}/chip-input.jsx (100%) rename src/{components => widgets}/chip.jsx (100%) rename src/{components => widgets}/contact-badges.jsx (100%) rename src/{components => widgets}/contact-list.jsx (100%) rename src/{components => widgets}/contact.jsx (100%) rename src/{components => widgets}/context-menu.jsx (100%) rename src/{components => widgets}/error-panel.jsx (100%) rename src/{components => widgets}/file-progress.jsx (100%) rename src/{components => widgets}/group-manager.jsx (100%) rename src/{components => widgets}/group-subs.jsx (100%) rename src/{components => widgets}/host-selector.jsx (100%) rename src/{components => widgets}/image-preview.jsx (100%) rename src/{components => widgets}/in-place-edit.jsx (100%) rename src/{components => widgets}/letter-tile.jsx (100%) rename src/{components => widgets}/load-spinner.jsx (100%) rename src/{components => widgets}/menu-cancel.jsx (100%) rename src/{components => widgets}/menu-contacts.jsx (100%) rename src/{components => widgets}/menu-start.jsx (100%) rename src/{components => widgets}/more-button.jsx (100%) rename src/{components => widgets}/new-topic-by-id.jsx (100%) rename src/{components => widgets}/new-topic-group.jsx (100%) rename src/{components => widgets}/permissions-editor.jsx (100%) rename src/{components => widgets}/received-marker.jsx (100%) rename src/{components => widgets}/search-contacts.jsx (100%) rename src/{components => widgets}/send-message.jsx (100%) rename src/{components => widgets}/side-navbar.jsx (100%) rename src/{components => widgets}/tag-manager.jsx (100%) rename src/{components => widgets}/unread-badge.jsx (100%) rename src/{components => widgets}/visible-password.jsx (100%) diff --git a/src/lib/utils.js b/src/lib/utils.js new file mode 100644 index 00000000..0e75dbbc --- /dev/null +++ b/src/lib/utils.js @@ -0,0 +1,415 @@ +// Helper functions for storing values in localStorage. +// By default localStorage can store only strings, not objects or other types. + +// Replace old object with the new one. +Storage.prototype.setObject = function(key, value) { + this.setItem(key, JSON.stringify(value)); +} + +// Get stored object. +Storage.prototype.getObject = function(key) { + let value = this.getItem(key); + return value && JSON.parse(value); +} + +// Partially or wholly update stored object. +Storage.prototype.updateObject = function(key, value) { + let oldVal = this.getObject(key); + this.setObject(key, Object.assign(oldVal || {}, value)); +} + +// Short representation of time in the past. +function shortDateFormat(then) { + var locale = window.navigator.userLanguage || window.navigator.language; + var now = new Date(); + if (then.getFullYear() == now.getFullYear()) { + if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) { + return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'}); + } else { + return then.toLocaleDateString(locale, + {hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); + } + } + return then.toLocaleDateString(locale, + {hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); +} + +// Convert a number of bytes to human-readable format. +function bytesToHumanSize(bytes) { + if (!bytes || bytes == 0) { + return '0 Bytes'; + } + + var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + var bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1); + var count = bytes / Math.pow(1024, bucket); + var round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0; + return count.toFixed(round) + ' ' + sizes[bucket]; +} + +// Make a data URL from public.photo +function makeImageUrl(photo) { + return (photo && photo.type && photo.data) ? + 'data:image/' + photo.type + ';base64,' + photo.data : null; +} + +// Calculate linear dimensions for scaling image down to fit under a certain size. +// Returns an object which contains destination sizes, source sizes, and offsets +// into source (when making square images). +function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) { + if (!width || !height || !maxWidth || !maxHeight) { + return null; + } + + if (forceSquare) { + maxWidth = maxHeight = Math.min(maxWidth, maxHeight); + } + + let scale = Math.min( + Math.min(width, maxWidth) / width, + Math.min(height, maxHeight) / height + ); + + let size = { + dstWidth: (width * scale) | 0, + dstHeight: (height * scale) | 0, + }; + + if (forceSquare) { + // Also calculate parameters for making the image square. + size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight); + size.srcWidth = size.srcHeight = Math.min(width, height); + size.xoffset = ((width - size.srcWidth) / 2) | 0; + size.yoffset = ((height - size.srcWidth) / 2) | 0; + } else { + size.xoffset = size.yoffset = 0; + size.srcWidth = width; + size.srcHeight = height; + } + return size; +} + +// Ensure file's extension matches mime content type +function fileNameForMime(fname, mime) { + var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime); + var ext = MIME_EXTENSIONS[idx]; + + var at = fname.lastIndexOf('.'); + if (at >= 0) { + fname = fname.substring(0, at); + } + return fname + '.' + ext; +} + +// Get mime type from data URL header. +function getMimeType(header) { + var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header); + return (mime && mime.length > 1) ? mime[1] : null; +} + +// Given length of a binary object in bytes, calculate the length after +// base64 encoding. +function base64EncodedLen(n) { + return Math.floor((n + 2) / 3) * 4; +} + +// Given length of a base64-encoded object, calculate decoded size of the +// pbject in bytes. +function base64DecodedLen(n) { + return Math.floor(n / 4) * 3; +} + +// Re-encode string to standard base64 encoding with padding. +// The string may be base64-URL encoded without the padding. +function base64ReEncode(str) { + if (str) { + str = str.replace('-', '+').replace('_', '/'); + try { + str = btoa(atob(str)); + } catch(err) { + console.log("Failed to base64 re-decode string"); + str = null; + } + } + return str; +} + +// Convert uploaded image into a base64-encoded string possibly scaling +// linear dimensions or constraining to a square. +function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) { + var img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onerror = function(err) { + onError("Image format unrecognized"); + } + img.onload = function() { + var dim = fitImageSize(this.width, this.height, width, height, forceSquare); + if (!dim) { + onError("Invalid image"); + return; + } + var canvas = document.createElement('canvas'); + canvas.width = dim.dstWidth; + canvas.height = dim.dstHeight; + var ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight, + 0, 0, dim.dstWidth, dim.dstHeight); + + var mime = (this.width != dim.dstWidth || + this.height != dim.dstHeight || + SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type; + var imageBits = canvas.toDataURL(mime); + var parts = imageBits.split(','); + // Get actual image type: 'data:image/png;base64,' + mime = getMimeType(parts[0]); + if (!mime) { + onError("Unsupported image format"); + return; + } + // Ensure the image is not too large + var quality = 0.78; + if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { + mime = 'image/jpeg'; + } + if (mime == 'image/jpeg') { + // Reduce size of the jpeg by reducing image quality + while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) { + imageBits = canvas.toDataURL(mime, quality); + quality *= 0.84; + } + } + if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { + onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) + + " exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err"); + return; + } + canvas = null; + onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime)); + }; + img.src = URL.createObjectURL(file); +} + +// Convert uploaded image file to base64-encoded string without scaling/converting the image +function imageFileToBase64(file, onSuccess, onError) { + var reader = new FileReader(); + reader.addEventListener('load', function() { + var parts = reader.result.split(','); + var mime = getMimeType(parts[0]); + if (!mime) { + onError("Failed to process image file"); + return; + } + + // Get image size. + var img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onload = function() { + onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime)); + } + img.onerror = function(err) { + onError("Image format not recognized"); + } + img.src = URL.createObjectURL(file); + }, false); + reader.readAsDataURL(file); +} + +function fileToBase64(file, onSuccess, onError) { + var reader = new FileReader(); + reader.addEventListener('load', function() { + onSuccess(file.type, reader.result.split(',')[1], file.name); + }); + reader.readAsDataURL(file); +} + +// File pasted from the clipboard. It's either an inline image or a file attachment. +// FIXME: handle large files out of band. +function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) { + var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items; + for (var i in items) { + var item = items[i]; + if (item.kind === 'file') { + var file = item.getAsFile(); + if (!file) { + console.log("Failed to get file object from pasted file item", item.kind, item.type); + continue; + } + if (file.type && file.type.split('/')[0] == 'image') { + // Handle inline image + if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) { + imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError); + } else { + imageFileToBase64(file, onImageSuccess, onError); + } + } else { + // Handle file attachment + fileToBase64(file, onAttachmentSuccess, onError) + } + // Indicate that the pasted data contains a file. + return true; + } + } + // No file found. + return false; +} + +// Make shortcut icon appear with a green dot + show unread count in title. +function updateFavicon(count) { + var oldIcon = document.getElementById('shortcut-icon'); + if (oldIcon) { + var head = document.head || document.getElementsByTagName('head')[0]; + var newIcon = document.createElement('link'); + newIcon.type = 'image/png'; + newIcon.id = 'shortcut-icon'; + newIcon.rel = 'shortcut icon'; + newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png'; + head.removeChild(oldIcon); + head.appendChild(newIcon); + } + document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode'; +} + +// Get 32 bit integer hash value for a string. Ideally it should produce the same value +// as Java's String#hash(). +function stringHash(value) { + var hash = 0; + value = '' + value; + for (var i = 0; i < value.length; i++) { + hash = ((hash << 5) - hash) + value.charCodeAt(i); + hash = hash & hash; // Convert to 32bit integer + } + return hash; +} + +// Helper functions for hash navigation. + +// Parse hash as in http://www.example.com/path#hash as if it were +// path and arguments. +function parseUrlHash(hash) { + // Split path from args, path -> parts[0], args->path[1] + let parts = hash.split('?', 2); + let params = {}; + let path = []; + if (parts[0]) { + path = parts[0].substr(1).split('/'); + } + if (parts[1]) { + parts[1].split('&').forEach(function(part) { + let item = part.split('='); + if (item[0]) { + params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]); + } + }); + } + return {path: path, params: params}; +} + +function navigateTo(url) { + window.location.hash = url; +} + +function composeUrlHash(path, params) { + var url = path.join('/'); + var args = []; + for (var key in params) { + if (params.hasOwnProperty(key)) { + args.push(key + '=' + params[key]); + } + } + if (args.length > 0) { + url += '?' + args.join('&'); + } + return url; +} + +function addUrlParam(hash, key, value) { + var parsed = parseUrlHash(hash); + parsed.params[key] = value; + return composeUrlHash(parsed.path, parsed.params); +} + +function removeUrlParam(hash, key) { + var parsed = parseUrlHash(hash); + delete parsed.params[key]; + return composeUrlHash(parsed.path, parsed.params); +} +function setUrlSidePanel(hash, sidepanel) { + var parsed = parseUrlHash(hash); + parsed.path[0] = sidepanel; + return composeUrlHash(parsed.path, parsed.params); +} +function setUrlTopic(hash, topic) { + var parsed = parseUrlHash(hash); + parsed.path[1] = topic; + // Close InfoView on topic change. + delete parsed.params.info; + return composeUrlHash(parsed.path, parsed.params); +} +// END of hash navigation helper functions + + +// Detect server address from the URL +function detectServerAddress() { + let host = DEFAULT_HOST; + if (typeof window.location == 'object') { + if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') { + host = KNOWN_HOSTS.local; + } else if (window.location.hostname) { + host = window.location.hostname + (window.location.port ? ':' + window.location.port : ''); + } + } + return host; +} + +// Detect if the page is served over HTTPS. +function isSecureConnection() { + if (typeof window.location == 'object') { + return window.location.protocol == 'https:'; + } + return false; +} + +function isLocalHost() { + if (typeof window.location == 'object') { + return window.location.hostname == 'localhost'; + } + return false; +} + +// Create VCard which represents topic 'public' info +function vcard(fn, imageDataUrl) { + var card = null; + + if ((fn && fn.trim()) || imageDataUrl) { + card = {}; + if (fn) { + card.fn = fn.trim(); + } + if (imageDataUrl) { + var dataStart = imageDataUrl.indexOf(','); + card.photo = { + data: imageDataUrl.substring(dataStart+1), + type: 'jpg' + }; + } + } + return card; +} + +// Deep-shallow compare two arrays: shallow compare each element. +function arrayEqual(a, b) { + // Compare lengths first. + if (a.length != b.length) { + return false; + } + // Order of elements is ignored. + a.sort(); + b.sort(); + for (var i = 0, l = a.length; i < l; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} diff --git a/src/components/contacts-view.jsx b/src/views/contacts-view.jsx similarity index 95% rename from src/components/contacts-view.jsx rename to src/views/contacts-view.jsx index 7d7769f0..9202014c 100644 --- a/src/components/contacts-view.jsx +++ b/src/views/contacts-view.jsx @@ -2,7 +2,7 @@ import React from 'react'; -import ContactList from './contact-list.jsx'; +import ContactList from '../widgets/contact-list.jsx'; export default class ContactsView extends React.Component { constructor(props) { diff --git a/src/components/create-account-view.jsx b/src/views/create-account-view.jsx similarity index 95% rename from src/components/create-account-view.jsx rename to src/views/create-account-view.jsx index d773087b..1c4c4033 100644 --- a/src/components/create-account-view.jsx +++ b/src/views/create-account-view.jsx @@ -2,9 +2,9 @@ import React from 'react'; -import AvatarUpload from './avatar-upload.jsx'; -import CheckBox from './checkbox.jsx'; -import VisiblePassword from './visible-password.jsx'; +import AvatarUpload from '../widgets/avatar-upload.jsx'; +import CheckBox from '../widgets/checkbox.jsx'; +import VisiblePassword from '../widgets/visible-password.jsx'; export default class CreateAccountView extends React.PureComponent { constructor(props) { diff --git a/src/components/edit-account-view.jsx b/src/views/edit-account-view.jsx similarity index 96% rename from src/components/edit-account-view.jsx rename to src/views/edit-account-view.jsx index 419dd31c..7dac93a8 100644 --- a/src/components/edit-account-view.jsx +++ b/src/views/edit-account-view.jsx @@ -1,9 +1,9 @@ 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'; +import AvatarUpload from '../widgets/avatar-upload.jsx'; +import CheckBox from '../widgets/checkbox.jsx'; +import PermissionsEditor from '../widgets/permissions-editor.jsx'; +import TagManager from '../widgets/tag-manager.jsx'; /* Edit account parameters */ diff --git a/src/components/info-view.jsx b/src/views/info-view.jsx similarity index 97% rename from src/components/info-view.jsx rename to src/views/info-view.jsx index d34aac8d..4a57d63b 100644 --- a/src/components/info-view.jsx +++ b/src/views/info-view.jsx @@ -2,15 +2,14 @@ import React from 'react'; -import AvatarUpload from './avatar-upload.jsx'; -import ContactList from './contact-list.jsx'; -import GroupManager from './group-manager.jsx'; -import InPlaceEdit from './in-place-edit.jsx'; -import MenuCancel from './menu-cancel.jsx'; -import MoreButton from './more-button.jsx'; -import PermissionsEditor from './permissions-editor.jsx'; -import TagManager from './tag-manager.jsx'; - +import AvatarUpload from '../widgets/avatar-upload.jsx'; +import ContactList from '../widgets/contact-list.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'; export default class InfoView extends React.Component { constructor(props) { diff --git a/src/components/login-view.jsx b/src/views/login-view.jsx similarity index 96% rename from src/components/login-view.jsx rename to src/views/login-view.jsx index 9f28696e..41ec5d8a 100644 --- a/src/components/login-view.jsx +++ b/src/views/login-view.jsx @@ -3,8 +3,8 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import CheckBox from './checkbox.jsx'; -import VisiblePassword from './visible-password.jsx'; +import CheckBox from '../widgets/checkbox.jsx'; +import VisiblePassword from '../widgets/visible-password.jsx'; export default class LoginView extends React.Component { constructor(props) { diff --git a/src/components/logo-view.jsx b/src/views/logo-view.jsx similarity index 100% rename from src/components/logo-view.jsx rename to src/views/logo-view.jsx diff --git a/src/components/messages-view.jsx b/src/views/messages-view.jsx similarity index 97% rename from src/components/messages-view.jsx rename to src/views/messages-view.jsx index d09dc48f..a1b46a35 100644 --- a/src/components/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -1,13 +1,13 @@ import React from 'react'; -import ChatMessage from './chat-message.jsx'; -import ErrorPanel from './error-panel.jsx'; -import GroupSubs from './group-subs.jsx'; -import ImagePreview from './image-preview.jsx'; -import LetterTile from './letter-tile.jsx'; -import LoadSpinner from './load-spinner.jsx'; +import ChatMessage from '../widgets/chat-message.jsx'; +import ErrorPanel from '../widgets/error-panel.jsx'; +import GroupSubs from '../widgets/group-subs.jsx'; +import ImagePreview from '../widgets/image-preview.jsx'; +import LetterTile from '../widgets/letter-tile.jsx'; +import LoadSpinner from '../widgets/load-spinner.jsx'; import LogoView from './logo-view.jsx'; -import SendMessage from './send-message.jsx'; +import SendMessage from '../widgets/send-message.jsx'; const NOT_FOUND_TOPIC_TITLE = "Not found"; diff --git a/src/components/new-topic-view.jsx b/src/views/new-topic-view.jsx similarity index 92% rename from src/components/new-topic-view.jsx rename to src/views/new-topic-view.jsx index 9a684ff4..07631bf1 100644 --- a/src/components/new-topic-view.jsx +++ b/src/views/new-topic-view.jsx @@ -2,10 +2,10 @@ import React from 'react'; -import ContactList from './contact-list.jsx'; -import NewTopicById from './new-topic-by-id.jsx'; -import NewTopicGroup from './new-topic-group.jsx'; -import SearchContacts from './search-contacts.jsx'; +import ContactList from '../widgets/contact-list.jsx'; +import NewTopicById from '../widgets/new-topic-by-id.jsx'; +import NewTopicGroup from '../widgets/new-topic-group.jsx'; +import SearchContacts from '../widgets/search-contacts.jsx'; export default class NewTopicView extends React.Component { constructor(props) { diff --git a/src/components/password-reset-view.jsx b/src/views/password-reset-view.jsx similarity index 96% rename from src/components/password-reset-view.jsx rename to src/views/password-reset-view.jsx index 748675a5..0ee6606e 100644 --- a/src/components/password-reset-view.jsx +++ b/src/views/password-reset-view.jsx @@ -2,7 +2,7 @@ import React from 'react'; -import VisiblePassword from './visible-password.jsx'; +import VisiblePassword from '../widgets/visible-password.jsx'; export default class PasswordResetView extends React.PureComponent { constructor(props) { diff --git a/src/components/settings-view.jsx b/src/views/settings-view.jsx similarity index 97% rename from src/components/settings-view.jsx rename to src/views/settings-view.jsx index 491a0da8..ce1eee89 100644 --- a/src/components/settings-view.jsx +++ b/src/views/settings-view.jsx @@ -1,7 +1,7 @@ // Tinode config panel. import React from 'react'; -import HostSelector from './host-selector.jsx'; +import HostSelector from '../widgets/host-selector.jsx'; export default class SettingsView extends React.PureComponent { constructor(props) { diff --git a/src/components/sidepanel-view.jsx b/src/views/sidepanel-view.jsx similarity index 97% rename from src/components/sidepanel-view.jsx rename to src/views/sidepanel-view.jsx index 6783c883..03751147 100644 --- a/src/components/sidepanel-view.jsx +++ b/src/views/sidepanel-view.jsx @@ -1,14 +1,15 @@ import React from 'react'; +import SideNavbar from '../widgets/side-navbar.jsx'; +import ErrorPanel from '../widgets/error-panel.jsx'; + import ContactsView from './contacts-view.jsx'; import CreateAccountView from './create-account-view.jsx'; import EditAccountView from './edit-account-view.jsx'; -import ErrorPanel from './error-panel.jsx'; import LoginView from './login-view.jsx'; import NewTopicView from './new-topic-view.jsx'; import PasswordResetView from './password-reset-view.jsx'; import SettingsView from './settings-view.jsx'; -import SideNavbar from './side-navbar.jsx'; import ValidationView from './validation-view.jsx'; export default class SidepanelView extends React.Component { diff --git a/src/components/tinode-web.jsx b/src/views/tinode-web.jsx similarity index 99% rename from src/components/tinode-web.jsx rename to src/views/tinode-web.jsx index f61d0de2..c3e117b5 100644 --- a/src/components/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -1,7 +1,11 @@ // The top-level class to hold all functionality together. import React from 'react'; -import ContextMenu from './context-menu.jsx'; +import 'firebase/app'; +import 'firebase/messaging'; + +import ContextMenu from '../widgets/context-menu.jsx'; + import InfoView from './info-view.jsx'; import MessagesView from './messages-view.jsx'; import SidepanelView from './sidepanel-view.jsx'; diff --git a/src/components/validation-view.jsx b/src/views/validation-view.jsx similarity index 100% rename from src/components/validation-view.jsx rename to src/views/validation-view.jsx diff --git a/src/webapp.jsx b/src/webapp.jsx index 8efde42c..9b523eb8 100644 --- a/src/webapp.jsx +++ b/src/webapp.jsx @@ -1,7 +1,3 @@ -// Babel JSX - -'use strict'; - if (typeof require == 'function') { if (typeof React == 'undefined') { var React = require('react'); @@ -77,417 +73,3 @@ const MAX_IMAGE_DIM = 768; // Supported image MIME types and corresponding file extensions. const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml']; const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg']; - -// Helper functions for storing values in localStorage. -// By default localStorage can store only strings, not objects or other types. - -// Replace old object with the new one. -Storage.prototype.setObject = function(key, value) { - this.setItem(key, JSON.stringify(value)); -} -// Get stored object. -Storage.prototype.getObject = function(key) { - let value = this.getItem(key); - return value && JSON.parse(value); -} -// Partially or wholly update stored object. -Storage.prototype.updateObject = function(key, value) { - let oldVal = this.getObject(key); - this.setObject(key, Object.assign(oldVal || {}, value)); -} - -// Short representation of time in the past. -function shortDateFormat(then) { - var locale = window.navigator.userLanguage || window.navigator.language; - var now = new Date(); - if (then.getFullYear() == now.getFullYear()) { - if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) { - return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'}); - } else { - return then.toLocaleDateString(locale, - {hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); - } - } - return then.toLocaleDateString(locale, - {hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); -} - -// Convert a number of bytes to human-readable format. -function bytesToHumanSize(bytes) { - if (!bytes || bytes == 0) { - return '0 Bytes'; - } - - var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - var bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1); - var count = bytes / Math.pow(1024, bucket); - var round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0; - return count.toFixed(round) + ' ' + sizes[bucket]; -} - -// Make a data URL from public.photo -function makeImageUrl(photo) { - return (photo && photo.type && photo.data) ? - 'data:image/' + photo.type + ';base64,' + photo.data : null; -} - -// Calculate linear dimensions for scaling image down to fit under a certain size. -// Returns an object which contains destination sizes, source sizes, and offsets -// into source (when making square images). -function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) { - if (!width || !height || !maxWidth || !maxHeight) { - return null; - } - - if (forceSquare) { - maxWidth = maxHeight = Math.min(maxWidth, maxHeight); - } - - let scale = Math.min( - Math.min(width, maxWidth) / width, - Math.min(height, maxHeight) / height - ); - - let size = { - dstWidth: (width * scale) | 0, - dstHeight: (height * scale) | 0, - }; - - if (forceSquare) { - // Also calculate parameters for making the image square. - size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight); - size.srcWidth = size.srcHeight = Math.min(width, height); - size.xoffset = ((width - size.srcWidth) / 2) | 0; - size.yoffset = ((height - size.srcWidth) / 2) | 0; - } else { - size.xoffset = size.yoffset = 0; - size.srcWidth = width; - size.srcHeight = height; - } - return size; -} - -// Ensure file's extension matches mime content type -function fileNameForMime(fname, mime) { - var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime); - var ext = MIME_EXTENSIONS[idx]; - - var at = fname.lastIndexOf('.'); - if (at >= 0) { - fname = fname.substring(0, at); - } - return fname + '.' + ext; -} - -// Get mime type from data URL header. -function getMimeType(header) { - var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header); - return (mime && mime.length > 1) ? mime[1] : null; -} - -// Given length of a binary object in bytes, calculate the length after -// base64 encoding. -function base64EncodedLen(n) { - return Math.floor((n + 2) / 3) * 4; -} - -// Given length of a base64-encoded object, calculate decoded size of the -// pbject in bytes. -function base64DecodedLen(n) { - return Math.floor(n / 4) * 3; -} - -// Re-encode string to standard base64 encoding with padding. -// The string may be base64-URL encoded without the padding. -function base64ReEncode(str) { - if (str) { - str = str.replace('-', '+').replace('_', '/'); - try { - str = btoa(atob(str)); - } catch(err) { - console.log("Failed to base64 re-decode string"); - str = null; - } - } - return str; -} - -// Convert uploaded image into a base64-encoded string possibly scaling -// linear dimensions or constraining to a square. -function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) { - var img = new Image(); - img.crossOrigin = 'Anonymous'; - img.onerror = function(err) { - onError("Image format unrecognized"); - } - img.onload = function() { - var dim = fitImageSize(this.width, this.height, width, height, forceSquare); - if (!dim) { - onError("Invalid image"); - return; - } - var canvas = document.createElement('canvas'); - canvas.width = dim.dstWidth; - canvas.height = dim.dstHeight; - var ctx = canvas.getContext('2d'); - ctx.imageSmoothingEnabled = true; - ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight, - 0, 0, dim.dstWidth, dim.dstHeight); - - var mime = (this.width != dim.dstWidth || - this.height != dim.dstHeight || - SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type; - var imageBits = canvas.toDataURL(mime); - var parts = imageBits.split(','); - // Get actual image type: 'data:image/png;base64,' - mime = getMimeType(parts[0]); - if (!mime) { - onError("Unsupported image format"); - return; - } - // Ensure the image is not too large - var quality = 0.78; - if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { - mime = 'image/jpeg'; - } - if (mime == 'image/jpeg') { - // Reduce size of the jpeg by reducing image quality - while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) { - imageBits = canvas.toDataURL(mime, quality); - quality *= 0.84; - } - } - if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) { - onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) + - " exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err"); - return; - } - canvas = null; - onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime)); - }; - img.src = URL.createObjectURL(file); -} - -// Convert uploaded image file to base64-encoded string without scaling/converting the image -function imageFileToBase64(file, onSuccess, onError) { - var reader = new FileReader(); - reader.addEventListener('load', function() { - var parts = reader.result.split(','); - var mime = getMimeType(parts[0]); - if (!mime) { - onError("Failed to process image file"); - return; - } - - // Get image size. - var img = new Image(); - img.crossOrigin = 'Anonymous'; - img.onload = function() { - onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime)); - } - img.onerror = function(err) { - onError("Image format not recognized"); - } - img.src = URL.createObjectURL(file); - }, false); - reader.readAsDataURL(file); -} - -function fileToBase64(file, onSuccess, onError) { - var reader = new FileReader(); - reader.addEventListener('load', function() { - onSuccess(file.type, reader.result.split(',')[1], file.name); - }); - reader.readAsDataURL(file); -} - -// File pasted from the clipboard. It's either an inline image or a file attachment. -// FIXME: handle large files out of band. -function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) { - var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items; - for (var i in items) { - var item = items[i]; - if (item.kind === 'file') { - var file = item.getAsFile(); - if (!file) { - console.log("Failed to get file object from pasted file item", item.kind, item.type); - continue; - } - if (file.type && file.type.split('/')[0] == 'image') { - // Handle inline image - if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) { - imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError); - } else { - imageFileToBase64(file, onImageSuccess, onError); - } - } else { - // Handle file attachment - fileToBase64(file, onAttachmentSuccess, onError) - } - // Indicate that the pasted data contains a file. - return true; - } - } - // No file found. - return false; -} - -// Make shortcut icon appear with a green dot + show unread count in title. -function updateFavicon(count) { - var oldIcon = document.getElementById('shortcut-icon'); - if (oldIcon) { - var head = document.head || document.getElementsByTagName('head')[0]; - var newIcon = document.createElement('link'); - newIcon.type = 'image/png'; - newIcon.id = 'shortcut-icon'; - newIcon.rel = 'shortcut icon'; - newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png'; - head.removeChild(oldIcon); - head.appendChild(newIcon); - } - document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode'; -} - -// Get 32 bit integer hash value for a string. Ideally it should produce the same value -// as Java's String#hash(). -function stringHash(value) { - var hash = 0; - value = '' + value; - for (var i = 0; i < value.length; i++) { - hash = ((hash << 5) - hash) + value.charCodeAt(i); - hash = hash & hash; // Convert to 32bit integer - } - return hash; -} - -// Helper functions for hash navigation. - -// Parse hash as in http://www.example.com/path#hash as if it were -// path and arguments. -function parseUrlHash(hash) { - // Split path from args, path -> parts[0], args->path[1] - let parts = hash.split('?', 2); - let params = {}; - let path = []; - if (parts[0]) { - path = parts[0].substr(1).split('/'); - } - if (parts[1]) { - parts[1].split('&').forEach(function(part) { - let item = part.split('='); - if (item[0]) { - params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]); - } - }); - } - return {path: path, params: params}; -} - -function navigateTo(url) { - window.location.hash = url; -} - -function composeUrlHash(path, params) { - var url = path.join('/'); - var args = []; - for (var key in params) { - if (params.hasOwnProperty(key)) { - args.push(key + '=' + params[key]); - } - } - if (args.length > 0) { - url += '?' + args.join('&'); - } - return url; -} - -function addUrlParam(hash, key, value) { - var parsed = parseUrlHash(hash); - parsed.params[key] = value; - return composeUrlHash(parsed.path, parsed.params); -} - -function removeUrlParam(hash, key) { - var parsed = parseUrlHash(hash); - delete parsed.params[key]; - return composeUrlHash(parsed.path, parsed.params); -} -function setUrlSidePanel(hash, sidepanel) { - var parsed = parseUrlHash(hash); - parsed.path[0] = sidepanel; - return composeUrlHash(parsed.path, parsed.params); -} -function setUrlTopic(hash, topic) { - var parsed = parseUrlHash(hash); - parsed.path[1] = topic; - // Close InfoView on topic change. - delete parsed.params.info; - return composeUrlHash(parsed.path, parsed.params); -} -// END of hash navigation helper functions - - -// Detect server address from the URL -function detectServerAddress() { - let host = DEFAULT_HOST; - if (typeof window.location == 'object') { - if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') { - host = KNOWN_HOSTS.local; - } else if (window.location.hostname) { - host = window.location.hostname + (window.location.port ? ':' + window.location.port : ''); - } - } - return host; -} - -// Detect if the page is served over HTTPS. -function isSecureConnection() { - if (typeof window.location == 'object') { - return window.location.protocol == 'https:'; - } - return false; -} - -function isLocalHost() { - if (typeof window.location == 'object') { - return window.location.hostname == 'localhost'; - } - return false; -} - -// Create VCard which represents topic 'public' info -function vcard(fn, imageDataUrl) { - var card = null; - - if ((fn && fn.trim()) || imageDataUrl) { - card = {}; - if (fn) { - card.fn = fn.trim(); - } - if (imageDataUrl) { - var dataStart = imageDataUrl.indexOf(','); - card.photo = { - data: imageDataUrl.substring(dataStart+1), - type: 'jpg' - }; - } - } - return card; -} - -// Deep-shallow compare two arrays: shallow compare each element. -function arrayEqual(a, b) { - // Compare lengths first. - if (a.length != b.length) { - return false; - } - // Order of elements is ignored. - a.sort(); - b.sort(); - for (var i = 0, l = a.length; i < l; i++) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -} diff --git a/src/components/attachment.jsx b/src/widgets/attachment.jsx similarity index 100% rename from src/components/attachment.jsx rename to src/widgets/attachment.jsx diff --git a/src/components/avatar-upload.jsx b/src/widgets/avatar-upload.jsx similarity index 100% rename from src/components/avatar-upload.jsx rename to src/widgets/avatar-upload.jsx diff --git a/src/components/chat-message.jsx b/src/widgets/chat-message.jsx similarity index 100% rename from src/components/chat-message.jsx rename to src/widgets/chat-message.jsx diff --git a/src/components/checkbox.jsx b/src/widgets/checkbox.jsx similarity index 100% rename from src/components/checkbox.jsx rename to src/widgets/checkbox.jsx diff --git a/src/components/chip-input.jsx b/src/widgets/chip-input.jsx similarity index 100% rename from src/components/chip-input.jsx rename to src/widgets/chip-input.jsx diff --git a/src/components/chip.jsx b/src/widgets/chip.jsx similarity index 100% rename from src/components/chip.jsx rename to src/widgets/chip.jsx diff --git a/src/components/contact-badges.jsx b/src/widgets/contact-badges.jsx similarity index 100% rename from src/components/contact-badges.jsx rename to src/widgets/contact-badges.jsx diff --git a/src/components/contact-list.jsx b/src/widgets/contact-list.jsx similarity index 100% rename from src/components/contact-list.jsx rename to src/widgets/contact-list.jsx diff --git a/src/components/contact.jsx b/src/widgets/contact.jsx similarity index 100% rename from src/components/contact.jsx rename to src/widgets/contact.jsx diff --git a/src/components/context-menu.jsx b/src/widgets/context-menu.jsx similarity index 100% rename from src/components/context-menu.jsx rename to src/widgets/context-menu.jsx diff --git a/src/components/error-panel.jsx b/src/widgets/error-panel.jsx similarity index 100% rename from src/components/error-panel.jsx rename to src/widgets/error-panel.jsx diff --git a/src/components/file-progress.jsx b/src/widgets/file-progress.jsx similarity index 100% rename from src/components/file-progress.jsx rename to src/widgets/file-progress.jsx diff --git a/src/components/group-manager.jsx b/src/widgets/group-manager.jsx similarity index 100% rename from src/components/group-manager.jsx rename to src/widgets/group-manager.jsx diff --git a/src/components/group-subs.jsx b/src/widgets/group-subs.jsx similarity index 100% rename from src/components/group-subs.jsx rename to src/widgets/group-subs.jsx diff --git a/src/components/host-selector.jsx b/src/widgets/host-selector.jsx similarity index 100% rename from src/components/host-selector.jsx rename to src/widgets/host-selector.jsx diff --git a/src/components/image-preview.jsx b/src/widgets/image-preview.jsx similarity index 100% rename from src/components/image-preview.jsx rename to src/widgets/image-preview.jsx diff --git a/src/components/in-place-edit.jsx b/src/widgets/in-place-edit.jsx similarity index 100% rename from src/components/in-place-edit.jsx rename to src/widgets/in-place-edit.jsx diff --git a/src/components/letter-tile.jsx b/src/widgets/letter-tile.jsx similarity index 100% rename from src/components/letter-tile.jsx rename to src/widgets/letter-tile.jsx diff --git a/src/components/load-spinner.jsx b/src/widgets/load-spinner.jsx similarity index 100% rename from src/components/load-spinner.jsx rename to src/widgets/load-spinner.jsx diff --git a/src/components/menu-cancel.jsx b/src/widgets/menu-cancel.jsx similarity index 100% rename from src/components/menu-cancel.jsx rename to src/widgets/menu-cancel.jsx diff --git a/src/components/menu-contacts.jsx b/src/widgets/menu-contacts.jsx similarity index 100% rename from src/components/menu-contacts.jsx rename to src/widgets/menu-contacts.jsx diff --git a/src/components/menu-start.jsx b/src/widgets/menu-start.jsx similarity index 100% rename from src/components/menu-start.jsx rename to src/widgets/menu-start.jsx diff --git a/src/components/more-button.jsx b/src/widgets/more-button.jsx similarity index 100% rename from src/components/more-button.jsx rename to src/widgets/more-button.jsx diff --git a/src/components/new-topic-by-id.jsx b/src/widgets/new-topic-by-id.jsx similarity index 100% rename from src/components/new-topic-by-id.jsx rename to src/widgets/new-topic-by-id.jsx diff --git a/src/components/new-topic-group.jsx b/src/widgets/new-topic-group.jsx similarity index 100% rename from src/components/new-topic-group.jsx rename to src/widgets/new-topic-group.jsx diff --git a/src/components/permissions-editor.jsx b/src/widgets/permissions-editor.jsx similarity index 100% rename from src/components/permissions-editor.jsx rename to src/widgets/permissions-editor.jsx diff --git a/src/components/received-marker.jsx b/src/widgets/received-marker.jsx similarity index 100% rename from src/components/received-marker.jsx rename to src/widgets/received-marker.jsx diff --git a/src/components/search-contacts.jsx b/src/widgets/search-contacts.jsx similarity index 100% rename from src/components/search-contacts.jsx rename to src/widgets/search-contacts.jsx diff --git a/src/components/send-message.jsx b/src/widgets/send-message.jsx similarity index 100% rename from src/components/send-message.jsx rename to src/widgets/send-message.jsx diff --git a/src/components/side-navbar.jsx b/src/widgets/side-navbar.jsx similarity index 100% rename from src/components/side-navbar.jsx rename to src/widgets/side-navbar.jsx diff --git a/src/components/tag-manager.jsx b/src/widgets/tag-manager.jsx similarity index 100% rename from src/components/tag-manager.jsx rename to src/widgets/tag-manager.jsx diff --git a/src/components/unread-badge.jsx b/src/widgets/unread-badge.jsx similarity index 100% rename from src/components/unread-badge.jsx rename to src/widgets/unread-badge.jsx diff --git a/src/components/visible-password.jsx b/src/widgets/visible-password.jsx similarity index 100% rename from src/components/visible-password.jsx rename to src/widgets/visible-password.jsx