// The top-level class to hold all functionality together. import React from 'react'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { initializeApp as firebaseInitApp } from 'firebase/app'; import { getMessaging as firebaseGetMessaging, getToken as firebaseGetToken, deleteToken as firebaseDelToken, onMessage as firebaseOnMessage } from 'firebase/messaging'; 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, FORWARDED_PREVIEW_LENGTH, LOGGING_ENABLED, MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js'; import { PACKAGE_VERSION } from '../version.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'; import { secondsToTime } from '../lib/strformat.js' import { updateFavicon } from '../lib/utils.js'; // Sound to play on message received. const POP_SOUND = new Audio('audio/msg.mp3'); const messages = defineMessages({ reconnect_countdown: { id: 'reconnect_countdown', defaultMessage: 'Disconnected. Reconnecting in {seconds}…', description: 'Message shown when an app update is available.' }, reconnect_now: { id: 'reconnect_now', defaultMessage: 'Try now', description: 'Prompt for reconnecting now' }, push_init_failed: { id: 'push_init_failed', defaultMessage: 'Failed to initialize push notifications', description: 'Error message when push notifications have failed to initialize.' }, invalid_security_token: { id: 'invalid_security_token', defaultMessage: 'Invalid security token', description: 'Error message when resetting password.' }, no_connection: { id: 'no_connection', defaultMessage: 'No connection', description: 'Warning that the user is offline.' }, code_doesnot_match: { id: 'code_doesnot_match', defaultMessage: 'Code does not match', description: 'Error message when the credential validation code is incorrect.' }, menu_item_info: { id: 'menu_item_info', defaultMessage: 'Info', description: 'Show extended topic information' } }); class TinodeWeb extends React.Component { constructor(props) { super(props); this.selfRef = React.createRef(); this.state = this.getBlankState(); this.handleResize = this.handleResize.bind(this); this.handleHashRoute = this.handleHashRoute.bind(this); this.handleOnline = this.handleOnline.bind(this); this.checkForAppUpdate = this.checkForAppUpdate.bind(this); this.handleVisibilityEvent = this.handleVisibilityEvent.bind(this); this.handleError = this.handleError.bind(this); this.handleLoginRequest = this.handleLoginRequest.bind(this); this.handlePersistenceChange = this.handlePersistenceChange.bind(this); this.handleConnected = this.handleConnected.bind(this); this.handleAutoreconnectIteration = this.handleAutoreconnectIteration.bind(this); this.doLogin = this.doLogin.bind(this); this.handleCredentialsRequest = this.handleCredentialsRequest.bind(this); this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this); this.handleDisconnect = this.handleDisconnect.bind(this); this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this); this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this); this.tnMeSubsUpdated = this.tnMeSubsUpdated.bind(this); this.resetContactList = this.resetContactList.bind(this); this.tnData = this.tnData.bind(this); this.tnInitFind = this.tnInitFind.bind(this); this.tnFndSubsUpdated = this.tnFndSubsUpdated.bind(this); this.handleSearchContacts = this.handleSearchContacts.bind(this); this.handleTopicSelected = this.handleTopicSelected.bind(this); this.handleHideMessagesView = this.handleHideMessagesView.bind(this); this.handleSendMessage = this.handleSendMessage.bind(this); this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this); this.handleNewAccount = this.handleNewAccount.bind(this); this.handleNewAccountRequest = this.handleNewAccountRequest.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); this.handleGlobalSettings = this.handleGlobalSettings.bind(this); this.handleShowArchive = this.handleShowArchive.bind(this); this.handleShowBlocked = this.handleShowBlocked.bind(this); this.handleToggleMessageSounds = this.handleToggleMessageSounds.bind(this); this.handleCredAdd = this.handleCredAdd.bind(this); this.handleCredDelete = this.handleCredDelete.bind(this); this.handleCredConfirm = this.handleCredConfirm.bind(this); this.initFCMessaging = this.initFCMessaging.bind(this); this.toggleFCMToken = this.toggleFCMToken.bind(this); this.handlePushMessage = this.handlePushMessage.bind(this); this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this); 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.handleTagsUpdateRequest = this.handleTagsUpdateRequest.bind(this); this.handleLogout = this.handleLogout.bind(this); this.handleDeleteAccount = this.handleDeleteAccount.bind(this); this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this); this.handleDeleteMessagesRequest = this.handleDeleteMessagesRequest.bind(this); this.handleLeaveUnsubRequest = this.handleLeaveUnsubRequest.bind(this); this.handleBlockTopicRequest = this.handleBlockTopicRequest.bind(this); this.handleReportTopic = this.handleReportTopic.bind(this); this.handleShowContextMenu = this.handleShowContextMenu.bind(this); this.defaultTopicContextMenu = this.defaultTopicContextMenu.bind(this); this.handleHideContextMenu = this.handleHideContextMenu.bind(this); this.handleShowAlert = this.handleShowAlert.bind(this); this.handleShowInfoView = this.handleShowInfoView.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() { const settings = LocalStorageUtil.getObject('settings') || {}; const persist = !!LocalStorageUtil.getObject('keep-logged-in'); return { connected: false, // Connected and subscribed to 'me' ready: false, // Try to re-login on new connection. autoLogin: false, transport: settings.transport || null, serverAddress: settings.serverAddress || detectServerAddress(), serverVersion: "no connection", // "On" is the default, so saving the "off" state. messageSounds: !settings.messageSoundsOff, incognitoMode: false, // Persistent request to enable alerts. desktopAlerts: persist && !!settings.desktopAlerts, // Enable / disable checkbox. desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) && (typeof firebaseInitApp != 'undefined') && (typeof navigator != 'undefined') && (typeof FIREBASE_INIT != 'undefined'), firebaseToken: persist ? LocalStorageUtil.getObject('firebase-token') : null, applicationVisible: !document.hidden, errorText: '', errorLevel: null, errorAction: undefined, errorActionText: null, sidePanelSelected: 'login', sidePanelTitle: null, sidePanelAvatar: null, myTrustedBadges: [], loadSpinnerVisible: false, login: '', password: '', persist: persist, myUserId: null, liveConnection: navigator.onLine, topicSelected: '', topicSelectedOnline: false, topicSelectedAcs: null, newTopicParams: null, loginDisabled: false, displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT), infoPanel: undefined, mobilePanel: 'sidepanel', contextMenuVisible: false, contextMenuBounds: null, contextMenuClickAt: null, contextMenuParams: null, contextMenuItems: [], forwardDialogVisible: false, forwardMessage: null, // Modal alert dialog. alertVisible: false, alertParams: {}, // Chats as shown in the ContactsView chatList: [], // Contacts returned by a search query. searchResults: [], // Merged results of a search query and p2p chats. searchableContacts: [], credMethod: undefined, credCode: undefined, // Topic to go to after login. requestedTopic: undefined }; } componentDidMount() { window.addEventListener('resize', this.handleResize); window.addEventListener('online', (e) => { this.handleOnline(true); }); window.addEventListener('offline', (e) => { this.handleOnline(false); }); window.addEventListener('hashchange', this.handleHashRoute); // Process background notifications from the service worker. if (typeof BroadcastChannel == 'function') { const serviceWorkerChannel = new BroadcastChannel('tinode-sw'); 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"); } // Window/tab visible or invisible for pausing timers. document.addEventListener('visibilitychange', this.handleVisibilityEvent); this.setState({ viewportWidth: document.documentElement.clientWidth, viewportHeight: document.documentElement.clientHeight }); new Promise((resolve, reject) => { this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport, this.props.intl.locale, this.state.persist, resolve); this.tinode.onConnect = this.handleConnected; this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration; }).then(() => { // Initialize desktop alerts. if (this.state.desktopAlertsEnabled) { this.initFCMessaging().then(() => { if (this.state.desktopAlerts) { this.tinode.setDeviceToken(this.state.firebaseToken); } }).catch(() => { // do nothing: handled earlier. // catch needed to pervent unnecessary logging of error. }); } // Parse and save the hash navigation params. const parsedNav = HashNavigation.parseUrlHash(window.location.hash); // Read contacts from cache. this.resetContactList(); const token = this.state.persist ? LocalStorageUtil.getObject('auth-token') : undefined; if (token) { this.setState({autoLogin: true}); // When reading from storage, date is returned as string. token.expires = new Date(token.expires); this.tinode.setAuthToken(token); this.tinode.connect().catch((err) => { // Socket error this.handleError(err.message, 'err'); }); } // Maybe navigate to home screen. if (!['cred', 'reset', 'register'].includes(parsedNav.path[0])) { // Save possible topic name. this.setState({requestedTopic: parsedNav.path[1]}); HashNavigation.navigateTo(''); } this.readTimer = null; this.readTimerCallback = null; this.handleHashRoute(); }); } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); window.removeEventListener('hashchange', this.handleHashRoute); document.removeEventListener('visibilitychange', this.handleVisibilityEvent); } // Setup transport (usually websocket) and server address. This will terminate connection with the server. static tnSetup(serverAddress, transport, locale, persistentCache, onSetupCompleted) { const tinode = new Tinode({appName: APP_NAME, host: serverAddress, apiKey: API_KEY, transport: transport, secure: isSecureConnection(), persist: persistentCache}, onSetupCompleted); tinode.setHumanLanguage(locale); tinode.enableLogging(LOGGING_ENABLED, true); return tinode; } // Tinode received a push notification from the server. handlePushMessage(payload) { const data = payload.data || {}; switch (data.what) { case 'msg': if (Tinode.isChannelTopicName(data.topic)) { // The last argument is a fake user Id: otherwise the update is seen as one from the current user. this.tinode.oobNotification('msg', data.topic, data.seq, 'fake-uid'); } break; case 'read': // Message read on another device, clear it here. this.tinode.oobNotification('read', data.topic, data.seq); break; default: console.warn("Unknown push type ignored", data.what, data); } } initFCMessaging() { const {formatMessage, locale} = this.props.intl; const onError = (msg, err) => { console.error(msg, err); this.handleError(formatMessage(messages.push_init_failed), 'err'); this.setState({desktopAlertsEnabled: false, firebaseToken: null}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); } try { this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, APP_NAME)); return navigator.serviceWorker.register('/service-worker.js').then((reg) => { this.checkForAppUpdate(reg); reg.active.postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION})); return reg; }).then((reg) => { // Request token. return TinodeWeb.requestFCMToken(this.fcm, reg); }).then((token) => { const persist = LocalStorageUtil.getObject('keep-logged-in'); if (token != this.state.firebaseToken) { this.tinode.setDeviceToken(token); if (persist) { LocalStorageUtil.setObject('firebase-token', token); } } this.setState({firebaseToken: token, desktopAlerts: true}); if (persist) { LocalStorageUtil.updateObject('settings', {desktopAlerts: true}); } // Handhe FCM pushes // (a) for channels always, // (b) pushes when the app is in foreground but has no focus. firebaseOnMessage(this.fcm, (payload) => { this.handlePushMessage(payload); }); }).catch((err) => { // SW registration or FCM has failed :( onError(err); throw err; }); } catch (err) { onError(err); return Promise.reject(err); } } // Google's FCM API is idiotic. static requestFCMToken(fcm, sw) { return firebaseGetToken(fcm, { serviceWorkerRegistration: sw, vapidKey: FIREBASE_INIT.messagingVapidKey }).then((token) => { if (token) { return token; } else { // Try to request permissions. return Notification.requestPermission().then((permission) => { if (permission === 'granted') { return firebaseGetToken(fcm, { serviceWorkerRegistration: reg, vapidKey: FIREBASE_INIT.messagingVapidKey }).then((token) => { if (token) { return token; } else { throw new Error("Failed to initialize notifications"); } }); } else { throw new Error("No permission to send notifications: " + permission); } }); } }); } handleResize() { const mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT; this.setState({ viewportWidth: document.documentElement.clientWidth, viewportHeight: document.documentElement.clientHeight }); if (this.state.displayMobile != mobile) { this.setState({displayMobile: mobile}); } } // Check if a newer version of TinodeWeb app is available at the server. checkForAppUpdate(reg) { reg.onupdatefound = () => { const installingWorker = reg.installing; installingWorker.onstatechange = () => { if (installingWorker.state == 'installed' && navigator.serviceWorker.controller) { const msg = <> .; this.handleError(msg, 'info'); } } } } // Handle for hashchange event: display appropriate panels. handleHashRoute() { 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','crop', 'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) { this.setState({sidePanelSelected: hash.path[0]}); } else { console.warn("Unknown sidepanel view", hash.path[0]); } // Topic for MessagesView selector. let topicName = hash.path[1] || null; if (topicName != this.state.topicSelected) { if (!Tinode.topicType(topicName)) { // Clear invalid topic name. topicName = null; } const newState = { topicSelected: topicName }; const acs = this.tinode.getTopicAccessMode(topicName); if (acs) { newState.topicSelectedAcs = acs; } this.setState(newState); } } else { // Empty hashpath this.setState({sidePanelSelected: '', topicSelected: null}); } // Save validation credentials, if available. if (hash.params.method) { this.setState({ credMethod: hash.params.method }); } if (hash.params.code) { this.setState({ credCode: hash.params.code }); } // Additional parameters of panels. this.setState({ infoPanel: hash.params.info, newTopicTabSelected: hash.params.tab }); } handleOnline(online) { if (online) { this.handleError(); clearInterval(this.reconnectCountdown); this.tinode.reconnect(); } else { this.handleError(this.props.intl.formatMessage(messages.no_connection), 'warn'); } this.setState({liveConnection: online}); } handleVisibilityEvent() { this.setState({applicationVisible: !document.hidden}); } handleError(err, level, action, actionText) { this.setState({errorText: err, errorLevel: level, errorAction: action, errorActionText: actionText}); } // User clicked Login button in the side panel. handleLoginRequest(login, password) { this.setState({ loginDisabled: true, login: login, password: password, loadSpinnerVisible: true, autoLogin: true }); this.handleError('', null); if (this.tinode.isConnected()) { this.doLogin(login, password, {meth: this.state.credMethod, resp: this.state.credCode}); } else { this.tinode.connect().catch((err) => { // Socket error this.setState({loginDisabled: false, autoLogin: false, loadSpinnerVisible: false}); this.handleError(err.message, 'err'); }); } } // Enable or disable saving the password and IndexedDB. handlePersistenceChange(persist) { if (persist) { this.tinode.initStorage().then(() => { LocalStorageUtil.setObject('keep-logged-in', true); this.setState({persist: true}); }); } else { this.tinode.clearStorage().then(() => { LocalStorageUtil.setObject('keep-logged-in', false); this.setState({persist: false}); }); } } // Connection succeeded. handleConnected() { // Just to be sure. clearInterval(this.reconnectCountdown); this.handleError(); const params = this.tinode.getServerInfo(); this.setState({ serverVersion: params.ver + ' ' + (params.build ? params.build : 'none') }); if (this.state.autoLogin) { this.doLogin(this.state.login, this.state.password, {meth: this.state.credMethod, resp: this.state.credCode}); } } // Called for each auto-reconnect iteration. handleAutoreconnectIteration(sec, prom) { clearInterval(this.reconnectCountdown); if (sec < 0) { // Clear error this.handleError(); return; } if (prom) { // Reconnecting now prom.then(() => { // Reconnected: clear error this.handleError(); }).catch((err) => { this.handleError(err.message, 'err'); }); return; } const {formatMessage} = this.props.intl; let count = sec / 1000; count = count | count; this.reconnectCountdown = setInterval(() => { const timeLeft = (count > 99) ? secondsToTime(count) : count; this.handleError( formatMessage(messages.reconnect_countdown, {seconds: timeLeft}), 'warn', () => { clearInterval(this.reconnectCountdown); this.tinode.reconnect(); }, formatMessage(messages.reconnect_now) ); count -= 1; }, 1000); } // Connection lost handleDisconnect(err) { this.setState({ connected: false, ready: false, topicSelectedOnline: false, errorText: err && err.message ? err.message : "Disconnected", errorLevel: err && err.message ? 'err' : 'warn', loginDisabled: false, contextMenuVisible: false, forwardDialogVisible: false, serverVersion: "no connection" }); } doLogin(login, password, cred) { if (this.tinode.isAuthenticated()) { // Already logged in. Go to default screen. HashNavigation.navigateTo(''); return; } // Sanitize and package credentail. 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; let token = this.tinode.getAuthToken(); if (login && password) { token = null; this.setState({password: null}); promise = this.tinode.loginBasic(login, password, cred); } else if (token) { promise = this.tinode.loginToken(token.token, cred); } if (promise) { promise.then((ctrl) => { if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { this.setState({loadSpinnerVisible: false}); if (cred) { this.handleError(this.props.intl.formatMessage(messages.code_doesnot_match), 'warn'); } this.handleCredentialsRequest(ctrl.params); } else { this.handleLoginSuccessful(); } }).catch((err) => { // Login failed, report error. this.setState({ loginDisabled: false, credMethod: undefined, credCode: undefined, loadSpinnerVisible: false, autoLogin: false }); this.handleError(err.message, 'err'); if (token) { this.handleLogout(); } HashNavigation.navigateTo(''); }); } else { // No login credentials provided. // Make sure we are on the login page. HashNavigation.navigateTo(''); this.setState({loginDisabled: false}); } } handleCredentialsRequest(params) { const parsed = HashNavigation.parseUrlHash(window.location.hash); parsed.path[0] = 'cred'; parsed.params['method'] = params.cred[0]; HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params)); } handleLoginSuccessful() { this.handleError(); // Refresh authentication token. if (LocalStorageUtil.getObject('keep-logged-in')) { LocalStorageUtil.setObject('auth-token', this.tinode.getAuthToken()); } const goToTopic = this.state.requestedTopic; // Logged in fine, subscribe to 'me' attaching callbacks from the contacts view. const me = this.tinode.getMeTopic(); me.onMetaDesc = this.tnMeMetaDesc; me.onContactUpdate = this.tnMeContactUpdate; me.onSubsUpdated = this.tnMeSubsUpdated; this.setState({ connected: true, credMethod: undefined, credCode: undefined, myUserId: this.tinode.getCurrentUserID(), autoLogin: true, requestedTopic: undefined, }); // Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched. me.subscribe( me.startMetaQuery(). withLaterSub(). withDesc(). withTags(). withCred(). build() ).catch((err) => { this.tinode.disconnect(); localStorage.removeItem('auth-token'); this.handleError(err.message, 'err'); HashNavigation.navigateTo(''); }).finally(() => { this.setState({loadSpinnerVisible: false}); }); let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'contacts'); if (goToTopic) { urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic); } HashNavigation.navigateTo(urlHash); } tnMeMetaDesc(desc) { if (desc) { if (desc.public) { this.setState({ sidePanelTitle: desc.public.fn, 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) { this.setState({ incognitoMode: !desc.acs.isPresencer() }); } } } // Reactions to updates to the contact list. tnMeContactUpdate(what, cont) { if (what == 'on' || what == 'off') { this.resetContactList(); if (this.state.topicSelected == cont.topic) { this.setState({topicSelectedOnline: (what == 'on')}); } } else if (what == 'read') { this.resetContactList(); } else if (what == 'msg') { // Check if the topic is archived. If so, don't play a sound. const topic = this.tinode.getTopic(cont.topic); const archived = topic && topic.isArchived(); // New message received. Maybe the message is from the current user, then unread is 0. if (cont.unread > 0 && this.state.messageSounds && !archived) { // Skip update if the topic is currently open, otherwise the badge will annoyingly flash. if (document.hidden || this.state.topicSelected != cont.topic) { POP_SOUND.play(); } } // Reorder contact list to use possibly updated 'touched'. this.resetContactList(); } else if (what == 'recv') { // Explicitly ignoring "recv" -- it causes no visible updates to contact list. } else if (what == 'gone' || what == 'unsub') { // Topic deleted or user unsubscribed. Remove topic from view. // If the currently selected topic is gone, clear the selection. if (this.state.topicSelected == cont.topic) { this.handleTopicSelected(null); } // Redraw without the deleted topic. this.resetContactList(); } else if (what == 'acs') { // Permissions changed. If it's for the currently selected topic, // update the views. if (this.state.topicSelected == cont.topic) { this.setState({topicSelectedAcs: cont.acs}); } } else if (what == 'del') { // TODO: messages deleted (hard or soft) -- update pill counter. } else if (what == 'upd') { // upd - handled by the SDK. Explicitly ignoring here. } else { // TODO(gene): handle other types of notifications: // * ua -- user agent changes (maybe display a pictogram for mobile/desktop). console.info("Unsupported (yet) presence update:" + what + " in: " + cont.topic); } } tnMeSubsUpdated(unused) { this.resetContactList(); } // Merge search results and contact list to create a single flat // list of known contacts for GroupManager to use. static prepareSearchableContacts(chatList, foundContacts) { const merged = {}; // For chatList topics merge only p2p topics and convert them to the // same format as foundContacts. for (const c of chatList) { if (Tinode.isP2PTopicName(c.topic)) { merged[c.topic] = { user: c.topic, updated: c.updated, public: c.public, private: c.private, acs: c.acs }; } } // Add all foundCountacts if they have not been added already. for (const c of foundContacts) { if (!merged[c.user]) { merged[c.user] = c; } } return Object.values(merged); } resetContactList() { const newState = { chatList: [] }; if (!this.state.ready) { newState.ready = true; } this.tinode.getMeTopic().contacts((c) => { if (!c.topic && !c.user) { // 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; newState.topicSelectedAcs = c.acs; } }); const past = new Date(0); newState.chatList.sort((a, b) => { return (a.touched || past).getTime() - (b.touched || past).getTime(); }); // Merge search results and chat list. newState.searchableContacts = TinodeWeb.prepareSearchableContacts(newState.chatList, this.state.searchResults); this.setState(newState); } // Sending "received" notifications tnData(data) { const topic = this.tinode.getTopic(data.topic); if (topic.msgStatus(data, true) >= Tinode.MESSAGE_STATUS_SENT && data.from != this.state.myUserId) { clearTimeout(this.receivedTimer); this.receivedTimer = setTimeout(() => { this.receivedTimer = undefined; topic.noteRecv(data.seq); }, RECEIVED_DELAY); } } /* Fnd topic: find contacts by tokens */ tnInitFind() { const fnd = this.tinode.getFndTopic(); fnd.onSubsUpdated = this.tnFndSubsUpdated; if (fnd.isSubscribed()) { this.tnFndSubsUpdated(); } else { fnd.subscribe(fnd.startMetaQuery().withSub().build()).catch((err) => { this.handleError(err.message, 'err'); }); } } tnFndSubsUpdated() { const foundContacts = []; // Don't attempt to create P2P topics which already exist. Server will reject the duplicates. this.tinode.getFndTopic().contacts((s) => { foundContacts.push(s); }); this.setState({ searchResults: foundContacts, searchableContacts: TinodeWeb.prepareSearchableContacts(this.state.chatList, foundContacts) }); } /** Called when the user enters a contact into the contact search field in the NewAccount panel @param query {Array} is an array of contacts to search for */ handleSearchContacts(query) { const fnd = this.tinode.getFndTopic(); fnd.setMeta({desc: {public: query}}).then((ctrl) => { return fnd.getMeta(fnd.startMetaQuery().withSub().build()); }).catch((err) => { this.handleError(err.message, 'err'); }); } // User clicked on a contact in the side panel or deleted a contact. handleTopicSelected(topicName) { // Clear newTopicParams after use. if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) { this.setState({ newTopicParams: null }); } if (topicName) { this.setState({ errorText: '', errorLevel: null, mobilePanel: 'topic-view', infoPanel: undefined }); // Different contact selected. if (this.state.topicSelected != topicName) { this.setState({ topicSelectedOnline: this.tinode.isTopicOnline(topicName), topicSelectedAcs: this.tinode.getTopicAccessMode(topicName), forwardMessage: null }); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName)); } } else { // Currently selected contact deleted this.setState({ errorText: '', errorLevel: null, mobilePanel: 'sidepanel', topicSelectedOnline: false, topicSelectedAcs: null, infoPanel: undefined, forwardMessage: null }); HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null)); } } // In mobile view user requested to show sidepanel handleHideMessagesView() { this.setState({ mobilePanel: 'sidepanel' }); HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null)); } // User is sending a message, either plain text or a drafty object, possibly // with attachments. // - msg - Drafty message with content // - promise - Promise to be resolved when the upload is completed // - uploader - for tracking progress // - 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(() => 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) { promise = promise.catch((err) => { this.handleError(err.message, 'err'); }); } topic.publishDraft(msg, promise) .then((ctrl) => { if (topic.isArchived()) { return topic.archive(false); } }) .catch((err) => { this.handleError(err.message, 'err'); }); } handleNewChatInvitation(topicName, action) { const topic = this.tinode.getTopic(topicName); let response = null; switch (action) { case 'accept': // Accept given permissions. const mode = topic.getAccessMode().getGiven(); response = topic.setMeta({sub: {mode: mode}}); if (topic.isP2PType()) { // For P2P topics change 'given' permission of the peer too. // In p2p topics the other user has the same name as the topic. response = response.then((ctrl) => { topic.setMeta({sub: {user: topicName, mode: mode}}); }); } break; case 'delete': // Ignore invitation by deleting it. response = topic.delTopic(true); break; case 'block': // Ban the topic making futher invites impossible. // Just self-ban. const am = topic.getAccessMode().updateWant('-JP').getWant(); response = topic.setMeta({sub: {mode: am}}).then((ctrl) => { return this.handleTopicSelected(null); }); break; default: console.warn("Unknown invitation action", '"' + action + '""'); } if (response != null) { response.catch((err) => { this.handleError(err.message, 'err'); }); } } // User chose a Sign Up menu item. handleNewAccount() { this.handleError(); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'register')); } // Actual registration of a new account. handleNewAccountRequest(login_, password_, public_, cred_, tags_) { // Clear old error, if any. this.handleError(); this.tinode.connect(this.state.serverAddress) .then(() => { return this.tinode.createAccountBasic(login_, password_, {public: public_, tags: tags_, cred: Tinode.credential(cred_)}); }).then((ctrl) => { if (ctrl.code >= 300 && ctrl.text == 'validate credentials') { this.handleCredentialsRequest(ctrl.params); } else { this.handleLoginSuccessful(this); } }).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'); }); } handleUpdateAccountTagsRequest(tags) { this.tinode.getMeTopic().setMeta({tags: tags}) .catch((err) => { this.handleError(err.message, 'err'); }); } // User chose Settings menu item. handleSettings() { this.handleError(); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, this.state.myUserId ? 'edit' : 'settings')); } // User updated global parameters. handleGlobalSettings(settings) { const serverAddress = settings.serverAddress || this.state.serverAddress; const transport = settings.transport || this.state.transport; if (this.tinode) { this.tinode.clearStorage(); this.tinode.onDisconnect = undefined; this.tinode.disconnect(); } this.tinode = TinodeWeb.tnSetup(serverAddress, transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in')); this.tinode.onConnect = this.handleConnected; this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration; this.setState({ serverAddress: serverAddress, transport: transport }); LocalStorageUtil.setObject('settings', { serverAddress: serverAddress, transport: transport }); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, '')); } // User chose 'Archived chats'. handleShowArchive() { HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, this.state.myUserId ? 'archive' : '')); } // User viewes 'Blocked chats'. handleShowBlocked() { HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, this.state.myUserId ? 'blocked' : '')); } toggleFCMToken(enabled) { if (enabled) { this.setState({desktopAlerts: null}); if (!this.state.firebaseToken) { this.initFCMessaging(); } else { this.setState({desktopAlerts: true}); if (LocalStorageUtil.getObject('keep-logged-in')) { LocalStorageUtil.updateObject('settings', {desktopAlerts: true}); } } } else if (this.state.firebaseToken && this.fcm) { firebaseDelToken(this.fcm).catch((err) => { console.error("Unable to delete token.", err); }).finally(() => { LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); localStorage.removeItem('firebase-token'); this.setState({desktopAlerts: false, firebaseToken: null}); // Inform the server that the token was deleted. this.tinode.setDeviceToken(null); }); } else { this.setState({desktopAlerts: false, firebaseToken: null}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); } } handleToggleMessageSounds(enabled) { this.setState({messageSounds: enabled}); LocalStorageUtil.updateObject('settings', { messageSoundsOff: !enabled }); } handleCredAdd(method, value) { const me = this.tinode.getMeTopic(); me.setMeta({cred: {meth: method, val: value}}).catch((err) => { this.handleError(err.message, 'err'); }); } handleCredDelete(method, value) { const me = this.tinode.getMeTopic(); me.delCredential(method, value).catch((err) => { this.handleError(err.message, 'err'); }); } handleCredConfirm(method, response) { this.handleCredentialsRequest({cred: [method]}); } // User clicked Cancel button in Setting or Sign Up panel. handleSidepanelCancel() { const parsed = HashNavigation.parseUrlHash(window.location.hash); 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) { path = 'contacts'; } parsed.path[0] = path; if (parsed.params) { delete parsed.params.code; delete parsed.params.method; delete parsed.params.tab; delete parsed.params.scheme; delete parsed.params.token; } HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params)); this.setState({errorText: '', errorLevel: null}); } // 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, newTopicParams, isChannel) { // Check if topic is indeed new. If not, launch it. if (topicName && this.tinode.isTopicCached(topicName)) { this.handleTopicSelected(topicName); return; } const params = {}; if (Tinode.isP2PTopicName(topicName)) { // Because we are initialing the subscription, set 'want' to all permissions. params.sub = {mode: DEFAULT_P2P_ACCESS_MODE}; // Give the other user all permissions too. params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}}; } else { topicName = topicName || this.tinode.newGroupTopicName(isChannel); if (newTopicParams) { 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 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. // Update the name of the selected topic first so the navigator doen't clear // the state. this.setState({topicSelected: newName}, () => { HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName)); }); } } 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 (typeof priv == 'string') { params.private = (priv === Tinode.DEL_CHAR) ? Tinode.DEL_CHAR : {comment: priv}; } if (defacs) { params.defacs = defacs; } 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'); }); } } handleChangePermissions(topicName, mode, uid) { const topic = this.tinode.getTopic(topicName); if (topic) { const am = topic.getAccessMode(); if (uid) { am.updateGiven(mode); mode = am.getGiven(); } else { am.updateWant(mode); mode = am.getWant(); } topic.setMeta({sub: {user: uid, mode: mode}}).catch((err) => { this.handleError(err.message, 'err'); }); } } handleTagsUpdateRequest(topicName, tags) { const topic = this.tinode.getTopic(topicName); if (topic) { topic.setMeta({tags: tags}).catch((err) => { this.handleError(err.message, 'err'); }); } } handleLogout() { updateFavicon(0); // Remove stored data. localStorage.removeItem('auth-token'); localStorage.removeItem('firebase-token'); localStorage.removeItem('settings'); if (this.state.firebaseToken) { firebaseDelToken(this.fcm); } clearInterval(this.reconnectCountdown); let cleared; if (this.tinode) { cleared = this.tinode.clearStorage(); this.tinode.onDisconnect = undefined; this.tinode.disconnect(); } else { cleared = Promose.resolve(); } this.setState(this.getBlankState()); cleared.then(() => { this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), () => { this.tinode.onConnect = this.handleConnected; this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration; HashNavigation.navigateTo(''); }) }); } handleDeleteAccount() { this.tinode.delCurrentUser(true).then((ctrl) => { this.handleLogout(); }); } handleDeleteTopicRequest(topicName) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } // Request to hard-delete topic. topic.delTopic(true).then((ctrl) => { // Hide MessagesView and InfoView panels. HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, '')); }).catch((err) => { this.handleError(err.message, 'err'); }); } handleDeleteMessagesRequest(topicName) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } // Request hard-delete all messages. topic.delMessagesAll(true).catch((err) => { this.handleError(err.message, 'err'); }); } handleLeaveUnsubRequest(topicName) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } topic.leave(true).then((ctrl) => { // Hide MessagesView and InfoView panels. HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, '')); }).catch((err) => { this.handleError(err.message, 'err'); }); } handleBlockTopicRequest(topicName) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } topic.updateMode(null, '-JP').then((ctrl) => { // Hide MessagesView and InfoView panels. HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, '')); }).catch((err) => { this.handleError(err.message, 'err'); }); } handleReportTopic(topicName) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } // Publish spam report. this.tinode.publish(Tinode.TOPIC_SYS, Tinode.Drafty.attachJSON(null, { 'action': 'report', 'target': topicName })); // Remove J and P permissions. topic.updateMode(null, '-JP').then((ctrl) => { // Hide MessagesView and InfoView panels. HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, '')); }).catch((err) => { this.handleError(err.message, 'err'); }); } handleShowContextMenu(params, menuItems) { this.setState({ contextMenuVisible: true, contextMenuClickAt: {x: params.x, y: params.y}, contextMenuParams: params, contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName), 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 = typeof params.content == 'string' ? Tinode.Drafty.init(params.content) : Tinode.Drafty.forwardedContent(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 } }); } defaultTopicContextMenu(topicName) { const topic = this.tinode.getTopic(topicName); let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false, archived = false; if (topic) { subscribed = topic.isSubscribed(); archived = topic.isArchived(); const acs = topic.getAccessMode(); if (acs) { muted = acs.isMuted(); blocked = !acs.isJoiner(); self_blocked = !acs.isJoiner('want'); deleter = acs.isDeleter(); } } return [ subscribed ? { title: this.props.intl.formatMessage(messages.menu_item_info), handler: this.handleShowInfoView } : null, subscribed ? 'messages_clear' : null, subscribed && deleter ? 'messages_clear_hard' : null, muted ? (blocked ? null : 'topic_unmute') : 'topic_mute', self_blocked ? 'topic_unblock' : 'topic_block', archived ? 'topic_restore' : 'topic_archive', 'topic_delete' ]; } handleHideContextMenu() { this.setState({ contextMenuVisible: false, contextMenuClickAt: null, contextMenuParams: null, contextMenuBounds: null }); } 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) { promise.then(() => { this.handleTopicSelected(null); }); } } else if (action == 'menu_item_forward') { this.handleShowForwardDialog(params); } } handleShowAlert(title, content, onConfirm, confirmText, onReject, rejectText) { this.setState({ alertVisible: true, alertParams: { title: title, content: content, onConfirm: onConfirm, confirm: confirmText, onReject: onReject, reject: rejectText } }); } handleShowInfoView() { HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info')); this.setState({infoPanel: 'info'}); } handleMemberUpdateRequest(topicName, added, removed) { if (!topicName) { return; } const topic = this.tinode.getTopic(topicName); if (!topic) { return; } if (added && added.length > 0) { added.map((uid) => { topic.invite(uid, null).catch((err) => { this.handleError(err.message, 'err'); }); }); } if (removed && removed.length > 0) { removed.map((uid) => { topic.delSubscription(uid).catch((err) => { this.handleError(err.message, 'err'); }); }); } } handleValidateCredentialsRequest(cred, code) { if (this.tinode.isAuthenticated()) { const me = this.tinode.getMeTopic(); me.setMeta({cred: {meth: cred, resp: code}}) .then(() => { HashNavigation.navigateTo(''); }) .catch((err) => { this.handleError(err.message, 'err'); }); } else { this.setState({credMethod: cred, credCode: code}); this.doLogin(null, null, {meth: cred, resp: code}); } } handlePasswordResetRequest(method, value) { // If already connected, connnect() will return a resolved promise. return this.tinode.connect() .then(() => { return this.tinode.requestResetAuthSecret('basic', method, value); }) .catch((err) => { // Socket error this.handleError(err.message, 'err'); }); } handleResetPassword(scheme, newPassword, token) { token = base64ReEncode(token); if (!token) { this.handleError(this.props.intl.formatMessage(messages.invalid_security_token), 'err'); } else { this.tinode.connect() .then(() => { return this.tinode.updateAccountBasic(null, null, newPassword, {token: token}); }) .then(() => { HashNavigation.navigateTo(''); }) .catch((err) => { // Socket error this.handleError(err.message, 'err'); }); } } render() { return (
{this.state.contextMenuVisible ? { if (topicName == this.state.topicSelected) { this.handleTopicSelected(null); } }} onError={this.handleError} /> : null } {this.state.forwardDialogVisible ? : null } { this.setState({alertVisible: false}); }) : null} reject={this.state.alertParams.reject} onConfirm={() => { this.setState({alertVisible: false}); this.state.alertParams.onConfirm(); }} confirm={this.state.alertParams.confirm} /> {this.state.infoPanel ? : null }
); } }; export default injectIntl(TinodeWeb);