// The top-level class to hold all functionality together. import React, { Suspense } 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 { Drafty, 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 BatchForwardDialog from '../widgets/batch-forward-dialog.jsx'; import CallIncoming from '../widgets/call-incoming.jsx'; import QrScanner, { GroupJoinPreview } from '../widgets/qr-scanner.jsx'; const PhoneCountrySelector = React.lazy(_ => import('../widgets/phone-country-selector.jsx')); const InfoView = React.lazy(_ => import('./info-view.jsx')); const MessagesView = React.lazy(_ => import('./messages-view.jsx')); import SidepanelView from './sidepanel-view.jsx'; import { API_KEY, APP_NAME, BASE_APP_NAME, DEFAULT_COLOR_SCHEME, DEFAULT_P2P_ACCESS_MODE, DEFAULT_TEXT_SIZE, FORWARDED_PREVIEW_LENGTH, LOGGING_ENABLED, MEDIA_BREAKPOINT, WAKE_UP_TICK, WAKE_UP_TIMEOUT } from '../config.js'; import { CALL_STATE_NONE, CALL_STATE_OUTGOING_INITATED, CALL_STATE_INCOMING_RECEIVED, CALL_STATE_IN_PROGRESS, CALL_HEAD_STARTED } from '../constants.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 { getTenantContext, resolveQRCode, createGroupJoinRequest, getBootstrap } from '../lib/im-api.js'; import { readCachedData, writeCachedData } from '../lib/use-cached-data.js'; import HashNavigation from '../lib/navigation.js'; import { secondsToTime } from '../lib/strformat.js' import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js'; // Sound to play on message received. const POP_SOUND = new Audio('audio/msg.m4a'); 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' }, menu_item_audio_call: { id: 'menu_item_audio_call', defaultMessage: 'Call', description: 'Start audio call' }, menu_item_video_call: { id: 'menu_item_video_call', defaultMessage: 'Video call', description: 'Start video call' }, cred_confirmed_successfully: { id: 'cred_confirmed_successfully', defaultMessage: 'Confirmed successfully', description: 'Notification message that the credential was successfully validated.' }, password_reset_success: { id: 'password_reset_success', defaultMessage: 'Password reset successfully', description: 'Notification message that the password was successfully reset.' }, select_country: { id: 'select_country', defaultMessage: 'Select country', description: 'Placeholder for the country selector' } }); // 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的 // "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的 // ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的 // 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。 const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','crop','edit','general', 'newtpk','notif','register','reset','security','settings','support','wallpapers', 'tenant','login','enterprise-register','enterprise-feedback','identity-check', 'realname','realname-verified','realname-reminder','independent-password', 'messages-home','messages-offline','messages-menu','message-actions','system-messages', 'chat-inbound-request','chat-inbound-message','chat-outbound-request','chat-status', 'chat-friend','chat-media','chat-blocked','chat-deleted','chat-standard', 'chat-attachments','chat-mixed','chat-contact-card', 'compose-keyboard','mentions-select','mentions-complete','mentions-message', 'emoji-panel','emoji-message','voice-ready','voice-hold','voice-recording', 'media-uploading','media-gallery','media-retry','upload-file','file-message', 'message-multiselect','favorite-success','forward-select','forward-multiple', 'forward-individual','forward-bundle', 'address-book','friend-requests','add-friend','my-friend-qr','friend-qr','friend-search-result', 'friend-verification','friend-profile','group-member-profile','friend-settings', 'friend-settings-blocked','friend-delete-confirm','block-list','block-confirm','clear-chat-confirm', 'group-list','group-empty','group-invite','group-events','group-join-requests', 'group-new-request','group-announcement','group-summary','group-announcement-detail', 'group-settings','group-profile','group-avatar','group-nickname','group-description', 'group-my-nickname','group-members','group-role-message','group-qr','group-governance','group-admins','group-admin-add', 'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add', 'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload', 'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice', 'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading', 'file-downloaded','history-search','history-files','history-links','history-images','history-videos', 'call-history','call-incoming','audio-call-ringing','audio-call-active','audio-call-muted', 'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active', 'video-call-peer-camera-off','call-minimized', 'workbench','organization-directory','meeting-organizer','meeting-attendee', 'meeting-create','meeting-detail', 'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail', 'monthly-report-editor','monthly-report-mine','monthly-report-team','monthly-report-detail', 'executive-mailbox','executive-mailbox-notice', 'personal-center','personal-profile','profile-nickname','profile-birth-date', 'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings', 'change-password', 'app-version','privacy-settings','friend-discovery-settings', 'youth-mode-off','youth-mode-on','youth-mode-verify', 'notification-permission','permission-settings-reminder','camera-permission-info', 'microphone-permission-info', 'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons', 'abuse-report-details','user-feedback', '']; // 登录流程本身的页面。从这些页面登录成功后应该落到工作台;从任何其它页面(刷新触发的自动登录) // 则应该留在原处。 const AUTH_FLOW_VIEWS = ['tenant', 'login', 'register', 'reset', 'cred', 'enterprise-register', 'enterprise-feedback']; // Tinode 原生 WS 登录失败走的是 {ctrl} 协议,不是 REST 层——im-api.js 里那张 // REASON_MESSAGES 翻译表只在 REST 请求里生效,这条路径完全绕开它。ctrl.text 是服务端 // datamodel.go 里固定的英文串(authentication failed / unknown authentication scheme 等), // ctrl.params 里也没有像 REST 层那样稳定的 reason 字段可用(已实测确认)。 // 原来这里直接把 err.message 原样显示,登录密码打错用户看到的是英文 "authentication failed", // 不是参考图要求的"账号或密码错误"。 // // 这张表原来按 err.message 精确匹配裸文案——但 tinode-sdk 的 CommError 构造时会把状态码拼进 // message 里("authentication failed (401)"),导致这张表实际上从未真正命中过:'authentication // failed' 之所以看起来工作正常,纯粹是因为紧接着的 401/403 兜底分支凑巧给出了同一句译文,掩盖 // 了匹配从未成立这件事;一旦命中 'unknown authentication scheme'(401,跟密码错误共用同一个状态 // 码,会被兜底分支误判成"账号或密码错误")或 'policy violation'(422,不在兜底范围内)这两种更 //少见的场景,用户就会看到带着 "(状态码)" 尾巴的原始英文。translateCtrlError 先去掉这段状态码 // 后缀再查表,两个问题一并解决。 // // 同一张表也被 handleDisconnect 复用,用来翻译 {hi} 阶段(登录请求发出之前)的连接失败——那个 // 场景下 permission denied 最常见的真实原因是租户被停用,不是"账号或密码错误",所以只在这里放 // 一份不带 401/403 兜底的裸翻译(loginErrorMessage 自己的兜底只应用在真正的登录尝试上)。 const CTRL_ERROR_TEXT_CN = { 'authentication failed': '账号或密码错误', 'authentication required': '请先登录', 'unknown authentication scheme': '登录方式不受支持,请刷新页面重试', 'policy violation': '当前操作不符合安全策略要求', 'permission denied': '没有权限执行此操作,请重新登录', 'internal error': '服务器繁忙,请稍后重试' }; function translateCtrlError(err) { const raw = err && err.message; if (!raw) return null; const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase(); return CTRL_ERROR_TEXT_CN[normalized] || null; } function loginErrorMessage(err) { const known = translateCtrlError(err); if (known) return known; // 未知文案时按状态码兜底,401/403 绝大多数场景就是账号或密码错误 if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误'; return (err && err.message) || '登录失败,请重试'; } // navigateToCredentialsView 拒绝导航时,ctrl.params.reason 用来给用户一句能看懂的话——目前只有 // INVITE_CODE_INVALID/INVITE_CODE_REQUIRED 两个真实用真实请求验证过,REVOKED/EXPIRED 是同一命名 // 规律下合理的兄弟值,未逐一实测,先按同一套文案兜底,不影响已确认的两个分支。 const INVITE_CODE_REASON_TEXT_CN = { INVITE_CODE_REQUIRED: '请填写邀请码', INVITE_CODE_INVALID: '邀请码错误,请重新输入', INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码', INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码' }; function invalidCredentialsReasonMessage(params) { const reason = params && params.reason; return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试'; } // See armCallRingTimeout below. const CALL_RING_TIMEOUT_MS = 60000; 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.handleEscapeKey = this.handleEscapeKey.bind(this); this.handleColorSchemeChange = this.handleColorSchemeChange.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.handleLoginSuccessful = this.handleLoginSuccessful.bind(this); this.handleTenantResolved = this.handleTenantResolved.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.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.handleImRegisterRequest = this.handleImRegisterRequest.bind(this); this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this); this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this); this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this); this.handleChangeColorSchema = this.handleChangeColorSchema.bind(this); this.handleChangeTextSize = this.handleChangeTextSize.bind(this); this.handleSendOnEnter = this.handleSendOnEnter.bind(this); this.handleSelectWallpapers = this.handleSelectWallpapers.bind(this); this.handleWallpaperSelected = this.handleWallpaperSelected.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.handleShareTheCard = this.handleShareTheCard.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.handleShowCountrySelector = this.handleShowCountrySelector.bind(this); this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this); this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this); this.handleEnterSelectMode = this.handleEnterSelectMode.bind(this); this.handleToggleMessageSelect = this.handleToggleMessageSelect.bind(this); this.handleExitSelectMode = this.handleExitSelectMode.bind(this); this.handleShowBatchForward = this.handleShowBatchForward.bind(this); this.handleHideBatchForward = this.handleHideBatchForward.bind(this); this.handleOpenScanner = this.handleOpenScanner.bind(this); this.handleCloseScanner = this.handleCloseScanner.bind(this); this.handleQrDetected = this.handleQrDetected.bind(this); this.handleSubmitGroupJoinRequest = this.handleSubmitGroupJoinRequest.bind(this); this.handleCancelGroupJoinPreview = this.handleCancelGroupJoinPreview.bind(this); this.handleStartVideoCall = this.handleStartVideoCall.bind(this); this.handleStartAudioCall = this.handleStartAudioCall.bind(this); this.handleInfoMessage = this.handleInfoMessage.bind(this); this.handleDataMessage = this.handleDataMessage.bind(this); this.handleCallClose = this.handleCallClose.bind(this); this.handleCallInvite = this.handleCallInvite.bind(this); this.handleCallRinging = this.handleCallRinging.bind(this); this.handleCallHangup = this.handleCallHangup.bind(this); this.handleCallSendOffer = this.handleCallSendOffer.bind(this); this.handleCallIceCandidate = this.handleCallIceCandidate.bind(this); this.handleCallSendAnswer = this.handleCallSendAnswer.bind(this); this.handleCallAccept = this.handleCallAccept.bind(this); this.sendMessageToTopic = this.sendMessageToTopic.bind(this); // FIXME: this is unused. this.callTimeoutTimer = null; } getBlankState() { const settings = LocalStorageUtil.getObject('settings') || {}; const persist = !!LocalStorageUtil.getObject('keep-logged-in'); if (!settings.wallpaper) { const wallpaper = defaultWallpaper(); settings.wallpaper = wallpaper.name || ''; settings.wallpaperSize = wallpaper.size || 0; settings.wallpaperBlur = 0; } 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(), secureConnection: settings.secureConnection === undefined ? isSecureConnection() : settings.secureConnection, serverVersion: "no connection", // "On" is the default, so saving the "off" state. messageSounds: !settings.messageSoundsOff, incognitoMode: false, // Manually selected color scheme. Default: 'system default' ('auto'). colorSchema: settings.colorSchema || DEFAULT_COLOR_SCHEME, systemColorSchema: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', // Wallpaper settings. wallpaper: settings.wallpaper, wallpaperSize: settings.wallpaperSize, wallpaperBlur: 0, // Text size in points. Default size: 10pt textSize: settings.textSize || DEFAULT_TEXT_SIZE, // Default: 'plain' (just Enter) sendOnEnter: settings.sendOnEnter || 'plain', // 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: 'tenant', sidePanelParams: {}, 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', // Video calls. callTopic: undefined, callState: CALL_STATE_NONE, callAudioOnly: undefined, // If true, call state should be transitioned to CALL_STATE_IN_PROGRESS upon // switching to the call topic. callShouldStart: false, contextMenuVisible: false, contextMenuBounds: null, contextMenuClickAt: null, contextMenuParams: null, contextMenuItems: [], forwardDialogVisible: false, forwardMessage: null, // Ch.4 multi-select: entered via the message context menu's "menu_item_select" item. selectMode: false, selectModeTopic: null, selectedSeqs: [], batchForwardVisible: false, // QR scanning ("扫一扫", Ch.2/5). Holds live camera state so it's modeled the same way as // the call panel/forward dialog above, not as a hash route -- there's nothing to deep-link // into mid-scan, and the camera stream needs a reliable single owner to tear down. scannerOpen: false, // Set once a "join_group" QR resolves to a real group the user isn't in yet (status // "can_request") -- holds enough of the group's summary to render the confirm sheet, plus // the scanned token so the join request can reference how it was created. groupJoinPreview: 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: [], // Credential validation. reqCredMethod: undefined, credMethod: undefined, credCode: undefined, credToken: undefined, // Topic to go to after login. requestedTopic: undefined }; } componentDidMount() { window.addEventListener('resize', this.handleResize); this.handleOnlineOn = _ => { this.handleOnline(true); } window.addEventListener('online', this.handleOnlineOn); this.handleOnlineOff = _ => { this.handleOnline(false); } window.addEventListener('offline', this.handleOnlineOff); window.addEventListener('hashchange', this.handleHashRoute); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange); // Window/tab visible or invisible for pausing timers. document.addEventListener('visibilitychange', this.handleVisibilityEvent); // Listen for Escape key to clear selections. document.addEventListener('keydown', this.handleEscapeKey); document.documentElement.style.colorScheme = this.state.colorSchema == 'auto' ? 'light dark' : this.state.colorSchema; document.documentElement.style.setProperty('--message-text-size', `${this.state.textSize}pt`); this.applyWallpaperSettings(this.state.wallpaper, this.state.wallpaperSize, this.state.wallpaperBlur, this.state.colorSchema == 'auto' ? this.state.systemColorSchema : this.state.colorSchema); // 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"); } // Detect if computer just woke up from sleep. Checks how much time has passed since the last tick. // If too much time has passed then the computer was asleep. this.lastWakeUpCheck = new Date().getTime(); this.wakeUpTicker = setInterval(_ => { const now = new Date().getTime(); if (now - this.lastWakeUpCheck > WAKE_UP_TIMEOUT) { // Trigger reconnect. this.handleOnlineOn(); } this.lastWakeUpCheck = now; }, WAKE_UP_TICK); this.setState({ viewportWidth: document.documentElement.clientWidth, viewportHeight: document.documentElement.clientHeight }); new Promise((resolve, reject) => { // Must match state.secureConnection's own fallback (line ~233), not just isSecureConnection() // directly -- otherwise a tenant resolved on a prior visit (which persists secureConnection via // handleTenantResolved) diverges from the client actually constructed here on this load, and // handleTenantResolved's own "did anything change" check (comparing against this same state) // then wrongly concludes there's nothing to reconnect. this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection, 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; this.tinode.onInfoMessage = this.handleInfoMessage; this.tinode.onDataMessage = this.handleDataMessage; }).then(_ => { // Initialize desktop alerts. if (this.state.desktopAlertsEnabled) { this.initFCMessaging().catch(_ => { // do nothing: handled earlier. // catch needed to pervent unnecessary logging of error. }); } // 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'); }); } // Parse the hash navigation params. const parsedNav = HashNavigation.parseUrlHash(window.location.hash); // Maybe navigate to home screen. if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) { // Save possible topic name. this.setState({requestedTopic: parsedNav.path[1]}); const path = parsedNav.params && parsedNav.params.cred_done ? HashNavigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done): ''; HashNavigation.navigateTo(path); } else { this.handleHashRoute(); } }); } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); window.removeEventListener('hashchange', this.handleHashRoute); window.removeEventListener('online', this.handleOnlineOn); window.removeEventListener('offline', this.handleOnlineOff); window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange); document.removeEventListener('visibilitychange', this.handleVisibilityEvent); document.removeEventListener('keydown', this.handleEscapeKey); clearInterval(this.wakeUpTicker); } // Setup transport (usually websocket) and server address. This will terminate connection with the server. static tnSetup(serverAddress, secureConnection, transport, locale, persistentCache, onSetupCompleted) { const tinode = new Tinode({appName: APP_NAME, host: serverAddress, apiKey: API_KEY, transport: transport, secure: secureConnection, persist: persistentCache}, onSetupCompleted); tinode.setHumanLanguage(locale); tinode.enableLogging(LOGGING_ENABLED, true); TinodeWeb.installTenantHiPatch(tinode); return tinode; } // The server extends two stock packets with fields the JS SDK has no API for (server/datamodel.go): // MsgClientHi.Tenant (`hi.tenant`, resolved via POST /v1/im/tenants/resolve) and MsgClientAcc's // `username`/`phone`/`invite_code` (used by server/user.go replyCreateUser during registration). // #initPacket is a private class field so the only usable hook is wrapping the connection's sendText // and injecting fields into the outgoing packet's already-serialized JSON. A cheap substring check // keeps this a no-op for the hot path (every {pub}/{note}/... also goes through sendText). static installTenantHiPatch(tinode) { const conn = tinode && tinode._connection; if (!conn || conn.__tenantPatched) { return; } const original = conn.sendText.bind(conn); conn.sendText = msg => { if (typeof msg == 'string' && (msg.indexOf('"hi":') >= 0 || msg.indexOf('"acc":') >= 0)) { try { const parsed = JSON.parse(msg); if (parsed.hi) { const tenant = getTenantContext(); if (tenant && tenant.code) { parsed.hi.tenant = tenant.code; msg = JSON.stringify(parsed); } } else if (parsed.acc && tinode.__pendingAccExtra) { Object.assign(parsed.acc, tinode.__pendingAccExtra); tinode.__pendingAccExtra = null; msg = JSON.stringify(parsed); } } catch (e) { // Not valid JSON -- send as is. } } return original(msg); }; conn.__tenantPatched = true; } // Tinode received a push notification from the server. handlePushMessage(payload) { this.tinode.oobNotification(payload.data || {}); } initFCMessaging() { const {locale} = this.props.intl; const onError = (msg, err) => { console.error(msg, err); // Was routed through the same handleError()/errorText the login/register form's own alert // renders from -- push notifications are best-effort (the app works fully without them), but // sharing that channel meant this could cover up a real, concurrent login/register error, or // (since initFCMessaging is called right as a login request starts) just surface on its own, // with no login error at all, on a form the user hasn't touched yet. Silently degrading // (console log + the state resets below, which already disable the desktop-alerts setting) // is enough; failing to enable push is not something that needs to interrupt the auth flow. this.setState({firebaseToken: null}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); } try { this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, BASE_APP_NAME)); return navigator.serviceWorker.getRegistration('/service-worker.js').then(reg => { return reg || navigator.serviceWorker.register('/service-worker.js').then(reg => { this.checkForAppUpdate(reg); return reg; }); }).then(reg => { // Pass locale and version config to the service worker. (reg.active || reg.installing).postMessage(JSON.stringify({locale: locale, version: PACKAGE_VERSION})); // 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 if (typeof Notification != 'undefined') { // Try to request permissions. return Notification.requestPermission().then(permission => { if (permission === 'granted') { return firebaseGetToken(fcm, { serviceWorkerRegistration: sw, 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); } }); } throw new Error("Notifications are not supported"); }); } 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}); } } handleEscapeKey(e) { if (e.key === 'Escape' || e.keyCode === 27) { // Clear selections and navigate to base state this.setState({ topicSelected: null, errorText: '', errorLevel: null, mobilePanel: 'sidepanel', topicSelectedOnline: false, topicSelectedAcs: null, infoPanel: undefined, forwardMessage: null, contextMenuVisible: false, forwardDialogVisible: false, alertVisible: false }); // Navigate to contacts or login view HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null)); } } // 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 hash navigation (hashchange) event: update state. handleHashRoute() { const hash = HashNavigation.parseUrlHash(window.location.hash); // Browser back navigation after a successful login re-renders one of the pre-login views // (most commonly 'login') while the underlying Tinode connection is still authenticated -- // hash routing and the WS session's lifecycle are independent, so nothing else here would // otherwise catch this. Landing back on a login/register form while already logged in used to // let a stale, disabled submit button sit there permanently (loginDisabled is only ever reset // on a real login attempt's own success/failure, and none happens here), and a form actually // submitted from this state hit doLogin's/handleNewAccountRequest's own "already authenticated" // short-circuit -- silently skipping credential validation for login, or surfacing a raw // untranslated "already authenticated (409)" for registration. Redirecting away before any of // that code runs removes the only way to reach it. if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) { HashNavigation.navigateTo(''); return; } // Start with panel parameters. const newState = { infoPanel: hash.params.info, newTopicTabSelected: hash.params.tab }; if (hash.path && hash.path.length > 0) { // Left-side panel selector. if (SIDE_PANEL_VIEWS.includes(hash.path[0])) { newState.sidePanelSelected = hash.path[0]; newState.sidePanelParams = hash.params; } 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 and hide messages view on mobile. topicName = null; newState.mobilePanel = 'sidepanel'; } else { // Topic valid: show messages view on mobile. newState.mobilePanel = 'topic-view'; } Object.assign(newState, { topicSelected: topicName, topicSelectedAcs: this.tinode.getTopicAccessMode(topicName) }); } } else { // Empty hashpath Object.assign(newState, {sidePanelSelected: '', sidePanelParams: {}, topicSelected: null}); } // Save credential validation parameters, if available. if (hash.params.method) { newState.credMethod = hash.params.method; } if (hash.params.code) { newState.credCode = hash.params.code; } if (hash.params.token) { newState.credToken = hash.params.token; } // Show a message if validation was successful. if (hash.params.cred_done) { Object.assign(newState, TinodeWeb.stateForError(this.props.intl.formatMessage(messages.cred_confirmed_successfully), 'info')); } this.setState(newState); } 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}); } static stateForError(err, level, action, actionText) { return { errorText: err, errorLevel: level, errorAction: action, errorActionText: actionText, callShouldStart: false, }; } handleError(err, level, action, actionText) { this.setState(TinodeWeb.stateForError(err, level, action, 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, null, {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'); }); } if (this.state.desktopAlertsEnabled && !this.state.firebaseToken) { // Firefox and Safari: "The Notification permission may only be requested from inside a // short running user-generated event handler". this.initFCMessaging(); } } // 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'), // "reqCred":{"auth":["email"]} reqCredMethod: ((params.reqCred || {}).auth || [])[0] || 'email' }); if (this.state.autoLogin) { this.doLogin(this.state.login, this.state.password, null, {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(_ => { if (count < -10) { // Count could go to negative if computer woke up from sleep. clearInterval(this.reconnectCountdown); this.tinode.reconnect(); return; } const fmtTime = (count > 99) ? secondsToTime(count) : count; this.handleError( formatMessage(messages.reconnect_countdown, {seconds: fmtTime}), 'warn', _ => { clearInterval(this.reconnectCountdown); this.tinode.reconnect(); }, formatMessage(messages.reconnect_now) ); count -= 1; }, 1000); } // Connection lost. Also covers a {hi} handshake rejected before login ever gets a chance to run // (e.g. the tenant being deactivated mid-session) -- that path calls onDisconnect, not the {login} // catch block down in doLogin, so without translateCtrlError here it showed the raw English // ctrl.text (e.g. "permission denied (403)") instead of any Chinese message at all. handleDisconnect(err) { const known = translateCtrlError(err); this.setState({ connected: false, ready: false, topicSelectedOnline: false, errorText: known || (err && err.message ? err.message : "Disconnected"), errorLevel: err && err.message ? 'err' : 'warn', loginDisabled: false, contextMenuVisible: false, forwardDialogVisible: false, serverVersion: "no connection" }); } doLogin(login, password, tmpToken, cred) { if (this.tinode.isAuthenticated()) { // Already logged in. Go to default screen. HashNavigation.navigateTo(''); return; } let token = tmpToken || (this.tinode.getAuthToken() || {}).token; if (!(login && password) && !token) { // No login credentials provided. // Make sure we are on the login page. HashNavigation.navigateTo(''); this.setState({loginDisabled: false}); return; } // Sanitize and package credentail. cred = Tinode.credential(cred); // May be disconnected. let connectionPromise = this.tinode.isConnected() ? Promise.resolve() : this.tinode.connect(); // Try to login with login/password. If they are not available, try token; if no token, ask for login/password. let loginPromise; if (login && password) { token = null; this.setState({password: null}); loginPromise = connectionPromise.then(_ => this.tinode.loginBasic(login, password, cred)); } else { loginPromise = connectionPromise.then(_ => this.tinode.loginToken(token, cred)); } loginPromise.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'); } if (!TinodeWeb.navigateToCredentialsView(ctrl.params) && !cred) { this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err'); } } else { this.handleLoginSuccessful(); } }).catch(err => { const autoLogin = err.code >= 500; // Connection or login failed, report error. this.setState({ loginDisabled: false, credMethod: undefined, credCode: undefined, loadSpinnerVisible: false, autoLogin: autoLogin }); this.handleError(loginErrorMessage(err), 'err'); console.warn("Login failed", err); if (!autoLogin) { if (token) { // 存的 token 失效(过期/被撤销)——这属于"会话没了",回到登录入口是对的。 this.handleLogout(); HashNavigation.navigateTo(''); } // 用户手输账号密码失败(401 之类)时**不要**导航。原来这里无条件 navigateTo(''), // 会把 sidePanelSelected 清成 '',render 里 '' 又落到 'tenant',于是密码打错一次就被 // 弹回企业码页,刚输的账号丢了;而 TenantEntry 不渲染 FormAlert、chapterOne 分支也不渲染 // ErrorPanel,那句错误提示用户根本看不到。留在登录页,errorText 才能显示出来。 } }); } // ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created, // now confirm a real credential (email code etc.)" -- ctrl.params.cred is guaranteed non-empty in // that case. This backend reuses the exact same code/text pair for its own invite-code errors // (INVITE_CODE_INVALID/INVITE_CODE_REQUIRED, confirmed live), which carry `reason`/`what` instead // of `cred` -- calling this without checking left `params.cred[0]` throwing a raw TypeError that // surfaced to the user as unreadable English. Returns whether it actually navigated, so both call // sites can show a real error message instead when it didn't. static navigateToCredentialsView(params) { if (!Array.isArray(params.cred) || !params.cred.length) { return false; } const parsed = HashNavigation.parseUrlHash(window.location.hash); parsed.path[0] = 'cred'; parsed.params['method'] = params.cred[0]; parsed.params['token'] = params.token; parsed.params['code'] = params.code; HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params)); return true; } handleLoginSuccessful() { this.handleError(); // Refresh authentication token. if (LocalStorageUtil.getObject('keep-logged-in')) { LocalStorageUtil.setObject('auth-token', this.tinode.getAuthToken()); } // im-api.js's REST calls (bootstrap, friends, ...) need a bearer token independent of whether the // user opted into surviving a browser restart -- sessionStorage covers "this tab's session", // matching how long the in-memory WS auth actually lasts when "keep logged in" is off. const authToken = this.tinode.getAuthToken(); sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); // On reconnect, requestedTopic is undefined but topicSelected may still be set. // Preserve the open topic so it gets restored after re-login. const goToTopic = this.state.requestedTopic || this.state.topicSelected; // 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, // Only the failure branch of doLogin's promise chain reset this; a success never needed to // since it navigates away immediately -- until the same Login form could be reached again // afterwards (browser back) with this still stuck true, permanently disabling the submit // button. handleHashRoute's redirect above now closes off that specific path, but resetting // it here too means this can't get stuck regardless of how the form ends up rendered again. loginDisabled: false, credMethod: undefined, credCode: undefined, credToken: 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}); }); // The new bottom nav only has "workbench" and "messages" -- there's no standalone contacts // entry point anymore, so landing a fresh login there orphaned the user outside the nav they can // actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks). // // 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件 // 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律 // 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。 const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0]; const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) || !SIDE_PANEL_VIEWS.includes(currentView); let urlHash = cameFromAuthFlow ? HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash; if (goToTopic) { urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic); } HashNavigation.navigateTo(urlHash); } tnMeMetaDesc(desc) { if (!desc) { return; } 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() }); } if (TinodeWeb.checkIfPinsUpdated(desc, this.state.chatList)) { this.resetContactList(); } } // 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' && cont) { // 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().catch(_ => { // play() throws if the user did not click the app first: https://goo.gl/xX8pDD. }); } } // 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' || what == 'call') { // upd, call - 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; } const me = this.tinode.getMeTopic(); me.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) => { // Pinned topics take precedence. const pins = me.pinnedTopicRank(b.topic) - me.pinnedTopicRank(a.topic); // Then sort by last touched time. return pins != 0 ? pins : (b.touched || past).getTime() - (a.touched || past).getTime(); }); // Merge search results and chat list. newState.searchableContacts = TinodeWeb.prepareSearchableContacts(newState.chatList, this.state.searchResults); this.setState(newState); } static checkIfPinsUpdated(me, chatList) { for (let i = 0; i < chatList.length; i++) { const c = chatList[i]; if (!!me.pinnedTopicRank(c.topic) != c.pinned) { return true; } } return false; } /* 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(_ => fnd.getMeta(fnd.startMetaQuery().withSub().build())) .catch(err => this.handleError(err.message, 'err')); } // User clicked on a topic in the side panel or deleted a topic. 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 topic 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({ topicSelected: null, 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 // - uploadCompletionPromise - for tracking progress // - head - head dictionary to be attached to the message handleSendMessage(msg, uploadCompletionPromise, uploader, head) { const topic = this.tinode.getTopic(this.state.topicSelected); return this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head); } sendMessageToTopic(topic, msg, uploadCompletionPromise, 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); } const completion = []; if (uploadCompletionPromise) { completion.push(uploadCompletionPromise); } if (!topic.isSubscribed()) { // Topic is not subscribed yet. Subscribe. const subscribePromise = topic.subscribe() .then(_ => { // If there are unsent messages (except video call messages), // try sending them now. Unsent video call messages will be dropped. let calls = []; topic.queuedMessages(pub => { if (pub._sending || pub.seq == msg.seq) { return; } if (pub.head && pub.head.webrtc) { // Filter out unsent video call messages. calls.push(pub.seq); return; } if (topic.isSubscribed()) { topic.publishMessage(pub); } }); if (calls.length > 0) { topic.delMessagesList(calls, true); } }); completion.push(subscribePromise); } // TODO: check if return is required. return topic.publishDraft(msg, Promise.all(completion)) .then(ctrl => { if (topic.isArchived()) { topic.archive(false); } return ctrl; }) .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(_ => 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(_ => 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(_ => { let attachments; if (public_ && public_.photo && public_.photo.ref) { attachments = [public_.photo.ref]; } return this.tinode.createAccountBasic(login_, password_, {public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments}); }).then(ctrl => { if (ctrl.code >= 300 && ctrl.text == 'validate credentials') { if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) { this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err'); } } else { this.handleLoginSuccessful(this); } }).catch(err => { // Registration goes over the native WS {acc} protocol, not REST, so im-api.js's // REASON_MESSAGES translation table (REST-only) never sees this error -- it was showing // raw SDK text like "internal error (500)" verbatim (confirmed live: retrying a phone // number that's already registered). CTRL_ERROR_TEXT_CN already translates this same class // of {ctrl} error for login; reused here with a registration-appropriate fallback instead // of loginErrorMessage's own (its 401/403 fallback is "账号或密码错误", which doesn't fit a // failed registration). const known = translateCtrlError(err); this.handleError(known || (err && err.message) || '注册失败,请稍后重试', 'err'); }); } // Registration via the product's username/phone flow (chapter 1.4). `extra` carries the fields the // server added to {acc} (username/phone/invite_code, see server/user.go resolveRegistrationAccount) -- // the SDK has no API for them, so they're injected by the sendText patch installed in tnSetup(). // `cred_` (when phone was verified through POST /v1/im/auth/verification-codes) is // {meth:'tel', val, resp: verification_token, params: {challenge_id}} -- see server/user.go // consumeProductVerificationCred, which validates resp/params against the REST-issued token. // // `login_` must equal what the server's own resolveRegistrationAccount will compute as the // account name, or replyCreateUser rejects the whole request with a policy violation before it // even looks at username/phone/cred: for phone registration that's the E.164 phone (not blank -- // registrationSecretMatches requires a non-empty match), for username registration it's the // username itself. handleImRegisterRequest(login_, password_, displayName, extra, cred_) { this.tinode.__pendingAccExtra = extra; this.handleNewAccountRequest(login_, password_, theCard(displayName, null), cred_); } 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'); }); } // Manual change of color scheme: 'dark', 'light', 'auto'. handleChangeColorSchema(schema) { this.setState({colorSchema: schema}); LocalStorageUtil.updateObject('settings', {colorSchema: schema}); document.documentElement.style.colorScheme = schema == 'auto' ? 'light dark' : schema; this.applyColorSchema(schema, this.state.systemColorSchema, this.state.wallpaperSize); } // System color scheme has changed. handleColorSchemeChange(event) { const systemSchema = event.matches ? 'dark' : 'light'; this.setState({systemColorSchema: systemSchema}); this.applyColorSchema(this.state.colorSchema, systemSchema, this.state.wallpaperSize); } applyColorSchema(schema, systemSchema, size) { const effectiveSchema = schema == 'auto' ? systemSchema : schema; document.documentElement.style.setProperty('--wallpaper-invert', effectiveSchema == 'dark' && size ? '1' : '0'); document.documentElement.style.setProperty('--wallpaper-brightness', effectiveSchema == 'dark' && !size ? '0.5' : '1'); } handleSelectWallpapers() { this.handleError(); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'wallpapers')); } applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) { document.documentElement.style.setProperty('--wallpaper-url', `url('${wallpaper}')`); document.documentElement.style.setProperty('--wallpaper-repeat', size ? 'repeat' : 'no-repeat'); document.documentElement.style.setProperty('--wallpaper-blur', size ? '0px' : `${blur}px`); document.documentElement.style.setProperty('--wallpaper-size', size ? `${size}px` : 'cover'); document.documentElement.style.setProperty('--wallpaper-position', size ? 'unset' : 'center'); document.documentElement.style.setProperty('--wallpaper-invert', effectiveSchema == 'dark' && size ? '1' : '0'); document.documentElement.style.setProperty('--wallpaper-brightness', effectiveSchema == 'dark' && !size ? '0.5' : '1'); } handleWallpaperSelected(wallpaper, size, blur) { this.handleError(); LocalStorageUtil.updateObject('settings', {wallpaper: wallpaper, wallpaperSize: size, wallpaperBlur: blur}); this.setState({wallpaper: wallpaper, wallpaperSize: size, wallpaperBlur: blur}); this.applyWallpaperSettings(wallpaper, size, blur, this.state.colorSchema == 'auto' ? this.state.systemColorSchema : this.state.colorSchema); } handleChangeTextSize(size) { this.setState({textSize: size | 0}); LocalStorageUtil.updateObject('settings', {textSize: size}); document.documentElement.style.setProperty('--message-text-size', `${size}pt`); } handleSendOnEnter(option) { this.setState({sendOnEnter: option}); LocalStorageUtil.updateObject('settings', {sendOnEnter: option}); } 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; const secureConnection = settings.secureConnection === undefined ? this.state.secureConnection : settings.secureConnection; if (this.tinode) { this.tinode.clearStorage(); this.tinode.onDisconnect = undefined; this.tinode.disconnect(); } this.tinode = TinodeWeb.tnSetup(serverAddress, secureConnection, 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.tinode.onInfoMessage = this.handleInfoMessage; this.tinode.onDataMessage = this.handleDataMessage; this.setState({ serverAddress: serverAddress, transport: transport, secureConnection: secureConnection, }); LocalStorageUtil.setObject('settings', { serverAddress: serverAddress, transport: transport, secureConnection: secureConnection, }); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, '')); } // Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry). // Persists the tenant context (read by im-api.js and by the {hi}-patching in tnSetup) and, if the // resolved tenant genuinely lives on a different host, rebuilds the Tinode client to point there. handleTenantResolved(data, requestedCode) { const tenant = data.tenant || {}; const endpoints = data.endpoints || {}; LocalStorageUtil.setObject('im-tenant', { code: tenant.code || requestedCode, name: tenant.name || tenant.code || requestedCode, endpoints }); let host = this.state.serverAddress; let secureConnection = this.state.secureConnection; if (endpoints.websocket) { try { const wsUrl = new URL(endpoints.websocket); host = wsUrl.host; secureConnection = wsUrl.protocol === 'wss:'; } catch (e) { // Malformed endpoint; keep the current host/scheme. } } if (host && (host !== this.state.serverAddress || secureConnection !== this.state.secureConnection)) { this.handleGlobalSettings({serverAddress: host, secureConnection}); } // Else: same host/scheme, nothing to reconnect -- the sendText patch installed in tnSetup() reads // the tenant context fresh on every {hi}, so the newly-persisted code takes effect immediately. } // 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) { TinodeWeb.navigateToCredentialsView({cred: [method], code: response}); } // 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','acc_general'].includes(parsed.path[0])) { path = 'edit'; } else if ('crop' == parsed.path[0]) { path = 'general'; } else if ('blocked' == parsed.path[0]) { path = 'security'; } else if ('wallpapers' == parsed.path[0]) { path = 'acc_general'; } 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 initiating 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) { let nextState = {}; if (this.state.callShouldStart) { nextState = {callState: CALL_STATE_IN_PROGRESS, callShouldStart: false}; } 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. nextState.topicSelected = newName; } this.setState(nextState, _ => { 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).catch(err => this.handleError(err.message, 'err')); } } handleUpdatePasswordRequest(password) { this.handleError(); if (!password) { return Promise.reject(new Error('密码不能为空')); } // 必须把 Promise 返回出去:调用方(personal-settings-view 的修改密码页)要靠它判断成败。 // 原来这里只在内部 catch 掉、什么都不返回,调用方拿到 undefined,于是密码策略被服务端拒绝时 // 用户照样看到"密码已更新"并被跳走。跟之前修过的 handleDeleteAccount 是同一类问题。 return this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password) .catch(err => { this.handleError(err.message, 'err'); throw 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'); sessionStorage.removeItem('im-session-token'); if (this.state.firebaseToken && this.fcm) { // Unsubscribe failures (e.g. 403 token-unsubscribe-failed) should not block logout. firebaseDelToken(this.fcm).catch(err => { console.warn('Failed to delete FCM token on logout', err); }); } // Reset color scheme. document.documentElement.style.colorScheme = DEFAULT_COLOR_SCHEME == 'auto' ? 'light dark' : DEFAULT_COLOR_SCHEME; document.documentElement.style.setProperty('--message-text-size', `${DEFAULT_TEXT_SIZE}pt`); clearInterval(this.reconnectCountdown); let cleared; if (this.tinode) { cleared = this.tinode.clearStorage(); this.tinode.onDisconnect = undefined; this.tinode.disconnect(); } else { cleared = Promise.resolve(); } this.setState(this.getBlankState()); cleared.then(_ => { // Same fix as the initial tnSetup call in componentDidMount: use the resolved // state.secureConnection (settings-aware), not a raw isSecureConnection() page-protocol check. this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection, 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; this.tinode.onInfoMessage = this.handleInfoMessage; this.tinode.onDataMessage = this.handleDataMessage; HashNavigation.navigateTo(''); }) }); } handleDeleteAccount() { return this.tinode.delCurrentUser(true).then(_ => { this.handleLogout(); }); } handleDeleteTopicRequest(topicName) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } // Request to hard-delete topic. topic.delTopic(true).then(_ => { // 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(_ => { // 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(_ => { // 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; } // The native `this.tinode.report('report', topicName)` call (WS pub to the "sys" topic) requires // auth.LevelRoot server-side (server/session.go) which a normal logged-in user never has -- it // silently fails permission checks and never reaches the abuse-report tables. Route to the real // REST reporting flow (im-api.js's getAbuseReasons/submitAbuseReport, backed by POST /reports/abuse) // instead of the old dead-end call. const isP2P = topic.isP2PType(); const targetName = topicDisplayName(topicName, topic.public, topicName); const params = isP2P ? {target_type: 'user', target_id: topicName, target_name: targetName} : {target_type: 'group', target_id: topicName, topic: topicName, target_name: targetName}; // Remove J and P permissions (the "block" half of "block and report"). topic.updateMode(null, '-JP') .then(_ => { let hash = HashNavigation.setUrlTopic(window.location.hash, ''); hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons'); Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); }); HashNavigation.navigateTo(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 ? '➦ ' + params.userName : ''; const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content); const preview = Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, true); const msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content); const msgPreview = Drafty.quote(header, params.userFrom, preview); const head = { forwarded: params.topicName + ':' + params.seq }; this.setState({ forwardDialogVisible: true, forwardMessage: { head: head, msg: msg, preview: msgPreview } }); } handleShareTheCard(card) { this.setState({ forwardDialogVisible: true, forwardMessage: { content: card, type: card.contentType, } }); } // Extracted out of defaultTopicContextMenu so the new 4.9-4.14 attachment-panel call shortcuts // (send-message.jsx) can gate on exactly the same real-world availability check as the existing // "..." menu items, instead of re-deriving (and risking drifting from) the same three conditions. canStartCall(topicName) { // Never checked whether a call was already in progress -- the "..." menu entry and // send-message.jsx's attachment-panel shortcut stayed clickable for the whole (very normal, // product-supported) duration of a minimized call. A second click re-arms // handleStartVideoCall/handleStartAudioCall's 60s ring timeout and forces callState back to // CALL_STATE_OUTGOING_INITATED with no visible effect (the already-mounted CallPanel doesn't // react to that prop), silently hanging up the real, healthy call a full minute later. if (this.state.callState != CALL_STATE_NONE) { return false; } const topic = this.tinode.getTopic(topicName); if (!topic || topic._deleted || !Tinode.isP2PTopicName(topicName)) { return false; } const acs = topic.getAccessMode(); const writer = !!(acs && acs.isWriter()); const cachedBootstrap = this.state.myUserId ? readCachedData('bootstrap:' + this.state.myUserId) : null; const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features || (cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false); return writer && callFeatureOk && !!this.tinode.getServerParam('iceServers'); } defaultTopicContextMenu(topicName) { const topic = this.tinode.getTopic(topicName); if (topic._deleted) { return [ 'topic_delete' ]; } const me = this.tinode.getMeTopic(); let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false, archived = false, pinned = false, webrtc = false, p2p = false, self = false; if (topic) { subscribed = topic.isSubscribed(); archived = topic.isArchived(); pinned = me.pinnedTopicRank(topicName) > 0; const acs = topic.getAccessMode(); if (acs) { muted = acs.isMuted(); blocked = !acs.isJoiner(); self_blocked = !acs.isJoiner('want'); deleter = acs.isDeleter(); } } // 后端今天新增了一个全局(非按租户)的通话总开关:{pub head:webrtc:...} 现在会先过 // im_client_feature 这个开关,关闭时整条消息直接被服务端拒绝。这个开关缺失/查询失败时是 // fail-closed(当成关闭),比 Tinode 协议层的 iceServers 配置更容易出现"某天突然打不通"的 // 情况。canStartCall 统一做这几条判断(也是 4.9-4.14 附件面板通话入口在用的同一份逻辑), // 按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,这里不做的话用户点了 // 按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。 webrtc = this.canStartCall(topicName); p2p = Tinode.isP2PTopicName(topicName); self = Tinode.isSelfTopicName(topicName); return [ subscribed ? { title: this.props.intl.formatMessage(messages.menu_item_info), handler: this.handleShowInfoView } : null, subscribed && p2p && webrtc ? { title: this.props.intl.formatMessage(messages.menu_item_audio_call), handler: this.handleStartAudioCall } : null, subscribed && p2p && webrtc ? { title: this.props.intl.formatMessage(messages.menu_item_video_call), handler: this.handleStartVideoCall } : null, subscribed ? 'messages_clear' : null, subscribed && deleter && !self ? 'messages_clear_hard' : null, self ? null : (muted ? (blocked ? null : 'topic_unmute') : 'topic_mute'), self ? null : (self_blocked ? 'topic_unblock' : 'topic_block'), pinned ? 'topic_unpin' : 'topic_pin', 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); } else if (action == 'menu_item_select') { this.handleEnterSelectMode(params); } } // Ch.4 multi-select: pre-selects the message that was long-pressed to reach "多选". handleEnterSelectMode(params) { this.setState({ selectMode: true, selectModeTopic: params.topicName, selectedSeqs: [params.seq] }); } handleToggleMessageSelect(seq) { const selectedSeqs = this.state.selectedSeqs.includes(seq) ? this.state.selectedSeqs.filter(s => s != seq) : this.state.selectedSeqs.concat(seq); this.setState({selectedSeqs}); } handleExitSelectMode() { this.setState({selectMode: false, selectModeTopic: null, selectedSeqs: [], batchForwardVisible: false}); } handleShowBatchForward() { this.setState({batchForwardVisible: true}); } handleHideBatchForward() { this.setState({batchForwardVisible: false}); } handleOpenScanner() { this.setState({scannerOpen: true}); } handleCloseScanner() { this.setState({scannerOpen: false}); } // `text` is whatever jsQR decoded from the camera frame -- only codes this app itself generated // (createMyQRCode/createGroupQRCode, both `im://qr?token=...&purpose=...`) resolve to anything; // a QR code from the outside world (a URL, a WiFi code) is reported as unrecognized rather than // silently failing the resolve call with a confusing server error. handleQrDetected(text) { const hasScheme = typeof text === 'string' && text.indexOf('im://qr') === 0; const params = hasScheme ? new URLSearchParams(text.slice(text.indexOf('?') + 1)) : null; const token = params ? params.get('token') : null; const purpose = params ? params.get('purpose') : null; if (!token) { this.setState({scannerOpen: false}); this.handleError('无法识别的二维码', 'err'); return; } resolveQRCode(token, purpose).then(data => { if (data.purpose === 'join_group' && data.group) { if (data.status === 'joined') { this.setState({scannerOpen: false}); this.handleTopicSelected(data.group.topic); } else { this.setState({scannerOpen: false, groupJoinPreview: {...data.group, qrToken: token}}); } return; } if (data.user) { this.setState({scannerOpen: false}); // The profile page this navigates to re-fetches via GET /users/{id}/profile, which 403s for // anyone who isn't already a friend (server-side privacy default) -- but resolveQRCode's // response already carries the same fields unconditionally, so stash it under the same // cache key contact-management-view.jsx's Profile/Verification read on mount, the same fix // applied there for the account-search entry point into this identical dead end. writeCachedData('profile-seed:' + data.user.user_id, { ...data.user, capabilities: {can_add_friend: !!data.user.can_add} }); let hash = HashNavigation.setUrlTopic(window.location.hash, ''); hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile'); hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id); HashNavigation.navigateTo(hash); return; } this.setState({scannerOpen: false}); this.handleError('无法识别的二维码', 'err'); }).catch(err => { this.setState({scannerOpen: false}); this.handleError(err.message || '二维码已失效', 'err'); }); } handleSubmitGroupJoinRequest(message) { const preview = this.state.groupJoinPreview; if (!preview) { return Promise.resolve(); } return createGroupJoinRequest(preview.topic, {message, source: 'qr', qrToken: preview.qrToken}).then(() => { this.setState({groupJoinPreview: null}); this.handleError('申请已提交,等待管理员通过', 'info'); }).catch(err => { this.handleError(err.message || '申请提交失败', 'err'); }); } handleCancelGroupJoinPreview() { this.setState({groupJoinPreview: null}); } handleShowAlert(title, content, onConfirm, confirmText, onReject, rejectText) { this.setState({ alertVisible: true, alertParams: { title: title, content: content, onConfirm: onConfirm, confirm: confirmText, onReject: onReject, reject: rejectText } }); } // The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx) // -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx // "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across // Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached // from an open chat's own header -- every test of them this engagement typed the hash directly. // InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed // parent per panel and never used real browser history, so routing here fixed both problems at once // rather than teaching the old view a second back-navigation scheme it's slated for removal anyway. handleShowInfoView() { const topicName = this.state.topicSelected; if (Tinode.isP2PTopicName(topicName)) { // Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds // the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current // hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/ // friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a // fresh topic selection, blanking the whole view -- the exact bug already found and fixed for // handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here. let hash = HashNavigation.setUrlSidePanel('', 'friend-settings'); hash = HashNavigation.addUrlParam(hash, 'uid', topicName); HashNavigation.navigateTo(hash); return; } if (!Tinode.isSelfTopicName(topicName)) { let hash = HashNavigation.setUrlSidePanel('', 'group-settings'); hash = HashNavigation.addUrlParam(hash, 'topic', topicName); HashNavigation.navigateTo(hash); return; } // "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it. 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.forEach(uid => { topic.invite(uid, null) .catch(err => this.handleError(err.message, 'err')); }); } if (removed && removed.length > 0) { removed.forEach(uid => { topic.delSubscription(uid) .catch(err => this.handleError(err.message, 'err')); }); } } handleValidateCredentialsRequest(cred, code, token) { if (this.tinode.isAuthenticated()) { // Adding new email or phone number in account setting. this.tinode.getMeTopic().setMeta({cred: {meth: cred, resp: code}}) .then(_ => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'contacts'))) .catch(err => this.handleError(err.message, 'err')); } else { // Credential validation on signup. this.setState({credMethod: cred, credCode: code, credToken: token}); this.doLogin(null, null, token, {meth: cred, resp: code}); } } handlePasswordResetRequest(method, value) { // If already connected, connnect() will return a resolved promise. return this.tinode.connect() .then(_ => this.tinode.requestResetAuthSecret('basic', method, value)) .catch(err => { // Socket error this.handleError(err.message, 'err'); }); } handleResetPassword(newPassword, tempAuth) { const secret = base64ReEncode(tempAuth.secret); if (!secret || !tempAuth.scheme) { this.handleError(this.props.intl.formatMessage(messages.invalid_security_token), 'err'); } else { this.tinode.connect() .then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {scheme: tempAuth.scheme, secret: secret})) .then(_ => { this.handleError(this.props.intl.formatMessage(messages.password_reset_success), 'info'); HashNavigation.navigateTo(''); }) .catch(err => { this.handleError(err.message, 'err'); }); } } handleShowCountrySelector(code, dial, selectedCallback) { this.handleShowAlert(this.props.intl.formatMessage(messages.select_country), }> { this.setState({alertVisible: false}); selectedCallback(c, d); }} /> , null, null, _ => {}, null); } handleStartVideoCall() { this.armCallRingTimeout(); this.setState({ callTopic: this.state.topicSelected, callState: CALL_STATE_OUTGOING_INITATED, callAudioOnly: false }); } handleStartAudioCall() { this.armCallRingTimeout(); this.setState({ callTopic: this.state.topicSelected, callState: CALL_STATE_OUTGOING_INITATED, callAudioOnly: true }); } // this.callTimeoutTimer was declared (and already read/cleared in handleCallClose) but never // actually assigned anywhere -- whose 'accept' {note} gets silently dropped, since every // call-signaling step after the initial invite is a fire-and-forget {note}, not a {pub} with a // server ack, left the caller's UI stuck on "呼叫中" forever, with manual hang-up the only way out. // This arms a 60s backstop that reuses the exact same hang-up path as clicking cancel by hand // (real 'hang-up' note to the peer + the usual top-level state reset), so it doesn't invent any // new call-outcome semantics. // // Note this is a secondary safety net, not the primary defense against a plain unanswered call -- // the backend already has its own call_establishment_timeout (30s, server/main.go in the im_be // reference repo) that ends a genuinely-unanswered call server-side and notifies both parties via // {info hang-up}, which handleInfoMessage's 'hang-up' case below already handles independently of // this timer. For that common case this 60s timer mostly just no-ops (state has already moved to // CALL_STATE_NONE by the time it fires). What it actually guards against is a narrower case: the // {info hang-up} notification carrying that server-side timeout is itself lost -- the same class // of signaling drop this whole mechanism exists for, just at a different stage. Kept comfortably // above the server's own 30s so it never races or duplicates that primary path. Deliberately scoped // to just the outgoing-ring wait: adding similar timeouts around mid-call offer/ice-candidate/ // answer signaling would risk false-positive hangups on connections that are just slow, not // actually dropped. armCallRingTimeout() { clearTimeout(this.callTimeoutTimer); this.callTimeoutTimer = setTimeout(() => { this.callTimeoutTimer = null; if (this.state.callState != CALL_STATE_OUTGOING_INITATED) { return; } this.handleCallHangup(this.state.callTopic, this.state.callSeq); this.handleCallClose(); }, CALL_RING_TIMEOUT_MS); } handleCallInvite(callTopic, callSeq, callState, audioOnly) { switch (callState) { case CALL_STATE_OUTGOING_INITATED: const head = { webrtc: CALL_HEAD_STARTED, aonly: !!audioOnly }; // sendMessageToTopic 自己的 .catch 会吞掉发送失败(只标记 pub._failed=true 让消息列表画 // 红色感叹号——这是给聊天气泡设计的反馈方式,通话邀请这条消息不会被用户看到,走这条路径 // 相当于什么反馈都没有),resolve 成 undefined 而不是 reject。原来这里 `ctrl.code` 直接 // 解引用,undefined.code 会抛 TypeError,变成一个没人 catch 的 rejected promise—— // handleCallClose() 根本不会被调用,通话面板会卡在"呼叫中"状态出不来,且用户不知道为什么。 // 今天这条路径已经有真实后果:新的全局通话开关 (im_client_feature) 关闭时服务端会拒绝这条 // {pub},命中的就是这个从未被验证过的失败分支。 return this.handleSendMessage(Drafty.videoCall(audioOnly), undefined, undefined, head) .then(ctrl => { if (!ctrl || ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) { this.handleError('通话发起失败,请稍后重试', 'err'); this.handleCallClose(); return ctrl; } this.setState({callSeq: ctrl.params['seq']}); return ctrl }).catch(err => { this.handleError((err && err.message) || '通话发起失败,请稍后重试', 'err'); this.handleCallClose(); }); case CALL_STATE_IN_PROGRESS: const topic = this.tinode.getTopic(callTopic); if (!topic) { return; } // We've accepted the call. Let the other side know. topic.videoCall('accept', callSeq); break; } } // All of the call-signaling senders below (ringing/hang-up/offer/ice-candidate/answer) go through // this one helper rather than calling topic.videoCall() directly. handleCallHangup in particular is // called from several unmount/teardown paths outside the normal call flow (messages-view.jsx's // componentWillUnmount and its topic-switch handler in componentSetup both call it via the // onCallHangup prop, on top of the ring-timeout path and CallInvite's own cleanup here) -- including // ones that fire *after* a logout has already torn down the connection: handleLogout() calls // this.tinode.disconnect() and only afterwards resets state to the blank/logged-out screen, and that // state reset is what unmounts messages-view.jsx and trips its componentWillUnmount -> handleCallHangup // call. Real repro: click a call button (real or scripted handleStartVideoCall/handleStartAudioCall), // then immediately log out -- topic.videoCall() ends up calling the vendored SDK's // Connection.sendText() on an already-closed socket, which throws synchronously ("Websocket is not // connected"). Thrown from componentWillUnmount, React has no error boundary for it and unmounts the // entire app to a blank white page. None of these are anything the peer can still receive over a dead // connection anyway (and for hang-up specifically, the server's own establishment-timeout already // cleans up an abandoned call-in-progress independently), so skip sending entirely once disconnected // instead of letting it throw -- the other four signals are currently unreachable in practice (no real // call can get established while "call invite always 400s" -- see handleCallInvite -- is unfixed) but // share the identical topic.videoCall() shape and would hit the exact same crash the moment that's // fixed and real in-progress calls (offer/ice-candidate/answer exchange, or a mid-call logout) become // possible. sendCallSignal(callTopic, evt, callSeq, payload) { const topic = this.tinode.getTopic(callTopic); if (!topic || !this.tinode.isConnected()) { return; } try { topic.videoCall(evt, callSeq, payload); } catch (err) { console.warn(`Failed to send call signal '${evt}'`, err); } } handleCallRinging(callTopic, callSeq) { this.sendCallSignal(callTopic, 'ringing', callSeq); } handleCallHangup(callTopic, callSeq) { this.sendCallSignal(callTopic, 'hang-up', callSeq); } handleCallSendOffer(callTopic, callSeq, sdp) { this.sendCallSignal(callTopic, 'offer', callSeq, sdp); } handleCallIceCandidate(callTopic, callSeq, candidate) { this.sendCallSignal(callTopic, 'ice-candidate', callSeq, candidate); } handleCallSendAnswer(callTopic, callSeq, sdp) { this.sendCallSignal(callTopic, 'answer', callSeq, sdp); } handleCallClose() { if (this.callTimeoutTimer) { clearTimeout(this.callTimeoutTimer); } this.setState({ callTopic: undefined, callState: CALL_STATE_NONE, callAudioOnly: undefined, // callSeq is otherwise only ever set once handleCallInvite's {pub} ack comes back (see its // .then() below), never cleared -- without resetting it here, a call ended before that ack // arrives (or armCallRingTimeout's hang-up firing before it) leaves this call's callSeq // pointing at whatever the *previous* call's seq was, and the next outgoing call could read // that stale value for the brief window before its own ack lands. callSeq: undefined, // handleCallAccept's "topic not subscribed yet" branch (below) sets this and waits for // handleNewTopicCreated to both flip callState to CALL_STATE_IN_PROGRESS and clear this flag // once that subscription succeeds -- but a call closed/abandoned (rejected, or the subscribe // itself failing) before that ever happens left this stuck at true with no owner left to clear // it. It would then get read by the *next*, entirely unrelated topic subscription that happens // to succeed, incorrectly forcing callState to CALL_STATE_IN_PROGRESS for a call that was // already given up on. callShouldStart: false }); } handleCallAccept(topicName, setCallTopic, callSeq) { clearTimeout(this.callTimeoutTimer); const topic = this.tinode.getTopic(topicName); if (!topic) { return; } if (topic.isSubscribed()) { this.handleTopicSelected(setCallTopic ? topicName : this.state.callTopic); const upd = { callState: CALL_STATE_IN_PROGRESS }; if (setCallTopic) { upd.callTopic = topicName; upd.callSeq = callSeq; } this.setState(upd); } else { // We need to switch and subscribe to callTopic first. this.setState({ callShouldStart: true, }, _ => this.handleTopicSelected(this.state.callTopic)); } } handleInfoMessage(info) { if (info.what != 'call') { return; } switch (info.event) { case 'accept': // Either branch below means the outgoing ring wait is over one way or another -- // see armCallRingTimeout. clearTimeout(this.callTimeoutTimer); // If another my session has accepted the call. if (Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) { this.setState({ callTopic: null, callState: CALL_STATE_NONE, callSeq: null, callAudioOnly: undefined, // Same stuck-flag risk handleCallClose() guards against -- this call is being given up // on here just as much as a local close/reject would, so it needs the same cleanup. callShouldStart: false }); return; } if (info.topic == this.state.callTopic) { // Update state. this.setState({callState: CALL_STATE_IN_PROGRESS}); } break; case 'hang-up': // Remote hangup. this.handleCallClose(); break; } } handleDataMessage(data) { if (data.head && data.head.webrtc && data.head.webrtc == CALL_HEAD_STARTED && Tinode.isP2PTopicName(data.topic)) { // If it's a video call invite message. // See if we need to display incoming call UI. const topic = this.tinode.getTopic(data.topic); if (topic) { // Check if a later version of the message exists (which means the call // has been either accepted or finished already). const msg = topic.latestMsgVersion(data.seq) || data; if (msg.head && msg.head.webrtc && msg.head.webrtc == CALL_HEAD_STARTED) { // This is a legit new call. if (data.from != this.state.myUserId) { if (this.state.callState == CALL_STATE_NONE) { // Incoming call. this.setState({ callTopic: data.topic, callState: CALL_STATE_INCOMING_RECEIVED, callSeq: data.seq, callAudioOnly: !!msg.head.aonly }); } else { // Another call is either in progress or being established. // Reject the incoming call. this.handleCallHangup(data.topic, data.seq); } } } } else { console.warn("Received vc data message from unknown topic", data.topic); } } } render() { const colorSchema = this.state.colorSchema !== 'auto' ? this.state.colorSchema : this.state.systemColorSchema; const activeProductView = this.state.sidePanelSelected || (this.state.myUserId ? 'messages-home' : 'tenant'); const chapterOneViews = ['tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'register', 'reset', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password']; const chapterOneActive = chapterOneViews.includes(activeProductView); const chapterTwoFullPage = ['messages-offline', 'messages-menu', 'message-actions', 'system-messages'].includes(activeProductView) || (!this.state.myUserId && activeProductView === 'messages-home'); const chapterThreeActive = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card'].includes(activeProductView); const chapterFourActive = ['compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message', 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording', 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message', 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle'].includes(activeProductView); const chapterFiveActive = ['address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm'].includes(activeProductView); const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description', 'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView); const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'].includes(activeProductView); const callExperienceActive = ['call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'call-minimized'].includes(activeProductView); const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView); const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'change-password', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info'].includes(activeProductView); const securitySupportActive = ['fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons', 'abuse-report-details', 'user-feedback'].includes(activeProductView); return (
{this.state.contextMenuVisible ? { if (topicName == this.state.topicSelected) { this.handleTopicSelected(null); } }} onError={this.handleError} /> : null } {this.state.forwardDialogVisible ? : null } {this.state.batchForwardVisible ? { this.handleHideBatchForward(); if (sent) { this.handleExitSelectMode(); } }} onInitFind={this.tnInitFind} searchResults={this.state.searchResults} onSearchContacts={this.handleSearchContacts} /> : null } {this.state.callTopic && this.state.callState == CALL_STATE_INCOMING_RECEIVED ? : null } {this.state.scannerOpen ? : null } {this.state.groupJoinPreview ? : null } {this.state.alertVisible ? this.setState({alertVisible: false})) : null} reject={this.state.alertParams.reject} onConfirm={this.state.alertParams.onConfirm ? (_ => {this.setState({alertVisible: false}); this.state.alertParams.onConfirm();}) : null} confirm={this.state.alertParams.confirm} /> : null} {!this.state.displayMobile || this.state.mobilePanel == 'sidepanel' ? : null} {!chapterOneActive && !chapterTwoFullPage && !chapterThreeActive && !chapterFourActive && !chapterFiveActive && !chapterSixActive && !chapterSevenActive && !callExperienceActive && !workplaceCollaborationActive && !personalSettingsActive && !securitySupportActive && (!this.state.displayMobile || (this.state.mobilePanel == 'topic-view' && !this.state.infoPanel)) ?
}> : null} {this.state.infoPanel ? }> : null } ); } }; export default injectIntl(TinodeWeb);