Files
im_webapp/src/views/tinode-web.jsx
T
Your NameandClaude Sonnet 5 eaafabe94f 修复用户名+邀请码注册(无手机号)必现崩溃:Tinode.credential(null) 抛异常
现象:企业码注册流程走"仅用户名+邀请码,不填手机号"这条路径时,点"注册"必然抛出
原始 JS 报错"Cannot destructure property 'val' of 'meth' as it is null",直接
展示在错误提示条上,注册无法完成。

根因:auth-onboarding-view.jsx 的 Register 组件在没有手机号凭证、只有邀请码时,
把 cred_ 显式传成 null(没有可附加的凭证)。tinode-sdk 的 Tinode.credential(meth,...)
用 `typeof meth == 'object'` 判断参数是不是一个 {meth,val,resp,params} 对象,但
JS 里 `typeof null === 'object'` 也是 true,导致对 null 无条件解构,直接抛异常。
所有走"登录"分支的调用点传的从来都是"字段可能是 undefined 的对象",从未传过字面量
null,所以这个 bug 只在"用户名注册且没有手机号凭证"这一条路径上必现。

修法:cred_ 为空时不再调用 Tinode.credential(),直接把 cred 字段设为 undefined,
绕开 SDK 这个不处理 null 输入的缺陷,不改动 vendored 的 tinode-sdk 代码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:19:26 +08:00

3096 lines
132 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
// message-center-view.jsx's conversation-list preview reads topic.latestMessage(), which the
// vendored SDK only ever populates from a topic's own local message cache -- itself only filled
// in once that specific topic has actually been subscribed with a data fetch. me.contacts()
// (resetContactList below) yields the real, already-cached Topic instances for every contact --
// including ones the user has never opened this session -- so most rows fall straight through to
// the '暂无消息' fallback despite `touched`/`seq` on the same object proving real history exists
// server-side. This tracks which topics we've already kicked off a one-shot "just the last
// message" fetch for, so repeated resetContactList() calls (it fires on nearly every {meta}/{pres}
// event, not just once at boot) don't re-subscribe the same topic over and over.
this.previewPrefetchAttempted = new Set();
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 = <>
<FormattedMessage id="update_available"
defaultMessage="Update available."
description="Message shown when an app update is available." /> <a href="">
<FormattedMessage id="reload_update"
defaultMessage="Reload"
description="Call to action to reload application when update is available." />
</a>.</>;
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;
}
});
this.prefetchLatestMessagePreviews(newState.chatList);
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);
}
// See the comment on this.previewPrefetchAttempted in the constructor for why this exists.
// seq > 0 is the same 'does this topic actually have any messages at all' signal
// message-center-view.jsx would otherwise have no way to distinguish from 'genuinely empty' --
// skip topics with no history instead of firing a subscribe that would just come back empty.
//
// !this.tinode.isConnected() live repro: resetContactList() fires as soon as the persisted local
// cache restores 'me's last-known contact list (log line "Persistent cache initialized"), which is
// *before* the websocket has even sent {hi} -- every subscribe() call made at that point instantly
// rejects with a 503 "Websocket is not connected". Skip attempting entirely while disconnected
// (rather than catching the failure) so these topics *aren't* added to previewPrefetchAttempted yet
// -- resetContactList() runs again moments later once the real {meta topic:'me'} response arrives
// over the now-live connection, and this gets a genuine second try then.
prefetchLatestMessagePreviews(chatList) {
if (!this.tinode.isConnected()) {
return;
}
for (const c of chatList) {
if (!c.topic || c.seq <= 0 || c.latestMessage() || this.previewPrefetchAttempted.has(c.topic)) {
continue;
}
this.previewPrefetchAttempted.add(c.topic);
// Topic.subscribe(getParams, setParams) -- the query is the *first* argument. Passing it
// second (as setParams) was silently swallowed: the resulting {sub} packet had no `get`
// clause at all (confirmed live: `out: {"sub":{"id":...,"topic":"..."}}`, no `get` key), so
// subscribe succeeded but never actually requested -- let alone received -- any message data.
c.subscribe(c.startMetaQuery().withLaterData(1).build()).catch(err => {
// Leave it in previewPrefetchAttempted either way -- a topic that fails once we're
// definitely connected (e.g. a stale contact the user no longer has access to) isn't going
// to succeed on the next resetContactList() a moment later either, and would otherwise retry
// on every single {meta}/{pres} event for the rest of the session.
console.warn(`Failed to prefetch latest message for '${c.topic}'`, err);
});
}
}
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 (this is the chat's own "<-" back arrow,
// #hide-message-view in messages-view.jsx). Opening a topic (handleTopicSelected) is a real
// navigateTo() push, so closing it should be the matching pop -- goBack() first, same pattern as
// content-library-view.jsx's Header back button. Before this fix it unconditionally pushed a new
// "no topic" hash entry instead of popping the "topic open" one: open chat A (push) -> tap back
// (push again, but the "chat A open" entry from a moment ago is left sitting right underneath,
// never consumed) -> open chat B (push) -> the system back button then has to be pressed twice to
// leave the chat area at all, and the second press lands back on chat A, not wherever the user
// actually came from before opening any chat.
handleHideMessagesView() {
this.setState({
mobilePanel: 'sidepanel'
});
if (!HashNavigation.goBack()) {
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];
}
// cred_ is `null` on the username-only registration path (auth-onboarding-view.jsx's Register
// passes it literally as `null` when there's no phone credential to attach, only an invite
// code). tinode-sdk's Tinode.credential(meth, ...) checks `typeof meth == 'object'` to decide
// whether its first argument is really a single {meth,val,resp,params} object -- but
// `typeof null === 'object'` is true in JS, so it unconditionally destructures `{val, params,
// resp, meth} = meth` and crashes with a raw "Cannot destructure property 'val' of 'meth' as
// it is null" the moment it receives a real `null` (as opposed to an object with merely
// undefined fields, which every *login* call site here happens to always pass and which
// destructures fine). This made username-only registration (with or without a valid invite
// code -- client-side validation passes either way before reaching here) always throw and
// never send the {acc} message at all, with the raw JS error text leaking into the "注册"
// button's error banner. Not calling Tinode.credential() at all when there is nothing to
// wrap sidesteps the SDK's bug without patching vendored node_modules code.
return this.tinode.createAccountBasic(login_, password_,
{public: public_, tags: tags_, cred: cred_ ? Tinode.credential(cred_) : undefined, 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),
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<PhoneCountrySelector
selected={code}
onSubmit={(c, d) => {
this.setState({alertVisible: false});
selectedCallback(c, d);
}} />
</Suspense>,
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 (
<div id="app-container" ref={this.selfRef}>
{this.state.contextMenuVisible ?
<ContextMenu
tinode={this.tinode}
bounds={this.state.contextMenuBounds}
clickAt={this.state.contextMenuClickAt}
params={this.state.contextMenuParams}
items={this.state.contextMenuItems}
hide={this.handleHideContextMenu}
onShowAlert={this.handleShowAlert}
onAction={this.handleContextMenuAction}
onTopicRemoved={(topicName) => {
if (topicName == this.state.topicSelected) {
this.handleTopicSelected(null);
}
}}
onError={this.handleError} />
:
null
}
{this.state.forwardDialogVisible ?
<ForwardDialog
tinode={this.tinode}
contacts={this.state.chatList}
topicSelected={this.state.topicSelected}
myUserId={this.state.myUserId}
hide={this.handleHideForwardDialog}
onInitFind={this.tnInitFind}
searchResults={this.state.searchResults}
onSearchContacts={this.handleSearchContacts}
onTopicSelected={this.handleStartTopicRequest}
/>
:
null
}
{this.state.batchForwardVisible ?
<BatchForwardDialog
tinode={this.tinode}
contacts={this.state.chatList}
topicSelected={this.state.topicSelected}
myUserId={this.state.myUserId}
topicName={this.state.selectModeTopic}
seqs={this.state.selectedSeqs}
hide={sent => { 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 ?
<CallIncoming
tinode={this.tinode}
topic={this.state.callTopic}
seq={this.state.callSeq}
callState={this.state.callState}
audioOnly={this.state.callAudioOnly}
onClose={this.handleCallClose}
onRinging={this.handleCallRinging}
onAcceptCall={this.handleCallAccept}
onReject={this.handleCallHangup}
/>
:
null
}
{this.state.scannerOpen ?
<QrScanner onDetect={this.handleQrDetected} onClose={this.handleCloseScanner} />
:
null
}
{this.state.groupJoinPreview ?
<GroupJoinPreview
group={this.state.groupJoinPreview}
onSubmit={this.handleSubmitGroupJoinRequest}
onCancel={this.handleCancelGroupJoinPreview}
/>
:
null
}
{this.state.alertVisible ?
<Alert
title={this.state.alertParams.title}
content={this.state.alertParams.content}
onReject={this.state.alertParams.onReject ? (_ => 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' ?
<SidepanelView
tinode={this.tinode}
connected={this.state.connected}
displayMobile={this.state.displayMobile}
state={this.state.sidePanelSelected}
hashParams={this.state.sidePanelParams}
title={this.state.sidePanelTitle}
avatar={this.state.sidePanelAvatar}
trustedBadges={this.state.myTrustedBadges}
login={this.state.login}
persist={this.state.persist}
myUserId={this.state.myUserId}
loginDisabled={this.state.loginDisabled}
loadSpinnerVisible={this.state.loadSpinnerVisible}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
errorAction={this.state.errorAction}
errorActionText={this.state.errorActionText}
topicSelected={this.state.topicSelected}
chatList={this.state.chatList}
credMethod={this.state.credMethod}
credCode={this.state.credCode}
credToken={this.state.credToken}
transport={this.state.transport}
messageSounds={this.state.messageSounds}
desktopAlerts={this.state.desktopAlerts}
desktopAlertsEnabled={this.state.desktopAlertsEnabled}
incognitoMode={this.state.incognitoMode}
serverAddress={this.state.serverAddress}
secureConnection={this.state.secureConnection}
serverVersion={this.state.serverVersion}
reqCredMethod={this.state.reqCredMethod}
textSize={this.state.textSize}
colorSchema={this.state.colorSchema}
wallpaper={this.state.wallpaper}
wallpaperSize={this.state.wallpaperSize}
wallpaperBlur={this.state.wallpaperBlur}
systemColorSchema={this.state.systemColorSchema}
sendOnEnter={this.state.sendOnEnter}
onGlobalSettings={this.handleGlobalSettings}
onSignUp={this.handleNewAccount}
onSettings={this.handleSettings}
onNavigate={this.basicNavigator}
onLoginRequest={this.handleLoginRequest}
onTenantResolved={this.handleTenantResolved}
onPersistenceChange={this.handlePersistenceChange}
onCreateAccount={this.handleNewAccountRequest}
onImRegisterRequest={this.handleImRegisterRequest}
onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
onTogglePushNotifications={this.toggleFCMToken}
onToggleMessageSounds={this.handleToggleMessageSounds}
onToggleIncognitoMode={this.handleToggleIncognitoMode}
onChangeColorSchema={this.handleChangeColorSchema}
onTextSizeChanged={this.handleChangeTextSize}
onSendOnEnterChanged={this.handleSendOnEnter}
onSelectWallpapers={this.handleSelectWallpapers}
onWallpaperSelected={this.handleWallpaperSelected}
onCredAdd={this.handleCredAdd}
onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected}
onOpenScanner={this.handleOpenScanner}
onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount}
onShowAlert={this.handleShowAlert}
onCancel={this.handleSidepanelCancel}
onError={this.handleError}
onValidateCredentials={this.handleValidateCredentialsRequest}
onPasswordResetRequest={this.handlePasswordResetRequest}
onResetPassword={this.handleResetPassword}
onShowArchive={this.handleShowArchive}
onShowBlocked={this.handleShowBlocked}
onShowCountrySelector={this.handleShowCountrySelector}
onInitFind={this.tnInitFind}
searchResults={this.state.searchResults}
onSearchContacts={this.handleSearchContacts}
showContextMenu={this.handleShowContextMenu} />
: null}
{!chapterOneActive && !chapterTwoFullPage && !chapterThreeActive && !chapterFourActive && !chapterFiveActive && !chapterSixActive && !chapterSevenActive && !callExperienceActive && !workplaceCollaborationActive && !personalSettingsActive && !securitySupportActive && (!this.state.displayMobile || (this.state.mobilePanel == 'topic-view' && !this.state.infoPanel)) ?
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<MessagesView
tinode={this.tinode}
connected={this.state.connected}
ready={this.state.ready}
online={this.state.topicSelectedOnline}
acs={this.state.topicSelectedAcs}
displayMobile={this.state.displayMobile}
viewportWidth={this.state.viewportWidth}
viewportHeight={this.state.viewportHeight}
topic={this.state.topicSelected}
jumpToSeq={this.state.sidePanelParams && this.state.sidePanelParams.jumpseq}
myUserId={this.state.myUserId}
// User public.fn.
myUserName={this.state.sidePanelTitle}
serverVersion={this.state.serverVersion}
serverAddress={this.state.serverAddress}
applicationVisible={this.state.applicationVisible}
colorSchema={colorSchema}
sendOnEnter={this.state.sendOnEnter}
wallpaper={this.state.wallpaper}
forwardMessage={this.state.forwardMessage}
onCancelForwardMessage={this.handleHideForwardDialog}
selectMode={this.state.selectMode && this.state.selectModeTopic == this.state.topicSelected}
selectedSeqs={this.state.selectedSeqs}
onToggleMessageSelect={this.handleToggleMessageSelect}
onExitSelectMode={this.handleExitSelectMode}
onShowBatchForward={this.handleShowBatchForward}
callTopic={this.state.callTopic}
callSeq={this.state.callSeq}
callState={this.state.callState}
callAudioOnly={this.state.callAudioOnly}
onCallHangup={this.handleCallHangup}
onAcceptCall={this.handleCallAccept}
onCallInvite={this.handleCallInvite}
onCallSendOffer={this.handleCallSendOffer}
onCallIceCandidate={this.handleCallIceCandidate}
onCallSendAnswer={this.handleCallSendAnswer}
canStartCall={this.state.topicSelected ? this.canStartCall(this.state.topicSelected) : false}
onStartAudioCall={this.handleStartAudioCall}
onStartVideoCall={this.handleStartVideoCall}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
errorAction={this.state.errorAction}
errorActionText={this.state.errorActionText}
newTopicParams={this.state.newTopicParams}
onHideMessagesView={this.handleHideMessagesView}
onError={this.handleError}
onNewTopicCreated={this.handleNewTopicCreated}
showContextMenu={this.handleShowContextMenu}
onChangePermissions={this.handleChangePermissions}
onNewChat={this.handleNewChatInvitation}
sendMessage={this.handleSendMessage}
onVideoCallClosed={this.handleCallClose} />
</Suspense>
: null}
{this.state.infoPanel ?
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<InfoView
tinode={this.tinode}
connected={this.state.connected}
displayMobile={this.state.displayMobile}
topic={this.state.topicSelected}
searchableContacts={this.state.searchableContacts}
myUserId={this.state.myUserId}
panel={this.state.infoPanel}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
errorAction={this.state.errorAction}
errorActionText={this.state.errorActionText}
onNavigate={this.infoNavigator}
onTopicDescUpdateRequest={this.handleTopicUpdateRequest}
onShowAlert={this.handleShowAlert}
onChangePermissions={this.handleChangePermissions}
onMemberUpdateRequest={this.handleMemberUpdateRequest}
onDeleteTopic={this.handleDeleteTopicRequest}
onDeleteMessages={this.handleDeleteMessagesRequest}
onLeaveTopic={this.handleLeaveUnsubRequest}
onBlockTopic={this.handleBlockTopicRequest}
onReportTopic={this.handleReportTopic}
onShareTheCard={this.handleShareTheCard}
onTopicTagsUpdateRequest={this.handleTagsUpdateRequest}
onTopicUnArchive={this.handleUnarchive}
onInitFind={this.tnInitFind}
onError={this.handleError}
showContextMenu={this.handleShowContextMenu}
/>
</Suspense>
:
null
}
</div>
);
}
};
export default injectIntl(TinodeWeb);