message-center-view.jsx 的预览文字读 topic.latestMessage(),这个方法
只会从话题自己的本地消息缓存里取值——而这个缓存只有在话题真正被
subscribe 过一次数据拉取后才会填充。me.contacts()(resetContactList)
给出的是每个联系人真实的、已缓存的Topic实例,包括用户这次会话里
从未打开过的——这些话题的latestMessage()恒为undefined,即使同一个
对象自己的touched/seq字段能证明服务端真实存在历史消息。
新增prefetchLatestMessagePreviews():对chatList里seq>0但本地缓存为空
的话题,一次性拉取最新1条消息填充预览。用一个Set跟踪已尝试过的话题,
避免resetContactList()(几乎每个{meta}/{pres}事件都会触发一次)反复
重新拉取同一个话题。
调试过程中发现并修正了自己犯的两个错误:(1) resetContactList()在
websocket握手完成前就会被本地持久化缓存恢复触发一次,此时的subscribe
必然503失败,加了isConnected()判断跳过,避免过早失败把话题永久标记
为"已尝试";(2) Topic.subscribe(getParams, setParams)的参数顺序搞反,
把查询对象传成了第二个参数(setParams),导致subscribe成功但发出的
{sub}包完全没有get子句,从未真正请求过消息数据。
已用真实浏览器验证:好友kcg051307会话(真实71条历史消息)刷新页面
后预览从"暂无消息"变为真实的最新消息内容,未读角标数字不受影响
(确认没有误将未读消息标记为已读)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3073 lines
130 KiB
React
3073 lines
130 KiB
React
// 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
|
||
handleHideMessagesView() {
|
||
this.setState({
|
||
mobilePanel: 'sidepanel'
|
||
});
|
||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
|
||
}
|
||
|
||
// User is sending a message, either plain text or a drafty object, possibly
|
||
// with attachments.
|
||
// - msg - Drafty message with content
|
||
// - promise - Promise to be resolved when the upload is completed
|
||
// - uploadCompletionPromise - for tracking progress
|
||
// - head - head dictionary to be attached to the message
|
||
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
|
||
const topic = this.tinode.getTopic(this.state.topicSelected);
|
||
return this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
|
||
}
|
||
|
||
sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head) {
|
||
msg = topic.createMessage(msg, false);
|
||
// The uploader is used to show progress.
|
||
msg._uploader = uploader;
|
||
|
||
if (head) {
|
||
msg.head = Object.assign(msg.head || {}, head);
|
||
}
|
||
|
||
const completion = [];
|
||
if (uploadCompletionPromise) {
|
||
completion.push(uploadCompletionPromise);
|
||
}
|
||
|
||
if (!topic.isSubscribed()) {
|
||
// Topic is not subscribed yet. Subscribe.
|
||
const subscribePromise =
|
||
topic.subscribe()
|
||
.then(_ => {
|
||
// If there are unsent messages (except video call messages),
|
||
// try sending them now. Unsent video call messages will be dropped.
|
||
let calls = [];
|
||
topic.queuedMessages(pub => {
|
||
if (pub._sending || pub.seq == msg.seq) {
|
||
return;
|
||
}
|
||
if (pub.head && pub.head.webrtc) {
|
||
// Filter out unsent video call messages.
|
||
calls.push(pub.seq);
|
||
return;
|
||
}
|
||
if (topic.isSubscribed()) {
|
||
topic.publishMessage(pub);
|
||
}
|
||
});
|
||
if (calls.length > 0) {
|
||
topic.delMessagesList(calls, true);
|
||
}
|
||
});
|
||
completion.push(subscribePromise);
|
||
}
|
||
|
||
// TODO: check if return is required.
|
||
return topic.publishDraft(msg, Promise.all(completion))
|
||
.then(ctrl => {
|
||
if (topic.isArchived()) {
|
||
topic.archive(false);
|
||
}
|
||
return ctrl;
|
||
})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
handleNewChatInvitation(topicName, action) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
let response = null;
|
||
switch (action) {
|
||
case 'accept':
|
||
// Accept given permissions.
|
||
const mode = topic.getAccessMode().getGiven();
|
||
response = topic.setMeta({sub: {mode: mode}});
|
||
if (topic.isP2PType()) {
|
||
// For P2P topics change 'given' permission of the peer too.
|
||
// In p2p topics the other user has the same name as the topic.
|
||
response = response.then(_ => topic.setMeta({sub: {user: topicName, mode: mode}}));
|
||
}
|
||
break;
|
||
case 'delete':
|
||
// Ignore invitation by deleting it.
|
||
response = topic.delTopic(true);
|
||
break;
|
||
case 'block':
|
||
// Ban the topic making futher invites impossible.
|
||
// Just self-ban.
|
||
const am = topic.getAccessMode().updateWant('-JP').getWant();
|
||
response = topic.setMeta({sub: {mode: am}}).then(_ => this.handleTopicSelected(null));
|
||
break;
|
||
default:
|
||
console.warn("Unknown invitation action", '"' + action + '""');
|
||
}
|
||
|
||
if (response != null) {
|
||
response.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
}
|
||
|
||
// User chose a Sign Up menu item.
|
||
handleNewAccount() {
|
||
this.handleError();
|
||
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'register'));
|
||
}
|
||
|
||
// Actual registration of a new account.
|
||
handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
|
||
// Clear old error, if any.
|
||
this.handleError();
|
||
|
||
this.tinode.connect(this.state.serverAddress)
|
||
.then(_ => {
|
||
let attachments;
|
||
if (public_ && public_.photo && public_.photo.ref) {
|
||
attachments = [public_.photo.ref];
|
||
}
|
||
return this.tinode.createAccountBasic(login_, password_,
|
||
{public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments});
|
||
}).then(ctrl => {
|
||
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
|
||
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
|
||
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
|
||
}
|
||
} else {
|
||
this.handleLoginSuccessful(this);
|
||
}
|
||
}).catch(err => {
|
||
// Registration goes over the native WS {acc} protocol, not REST, so im-api.js's
|
||
// REASON_MESSAGES translation table (REST-only) never sees this error -- it was showing
|
||
// raw SDK text like "internal error (500)" verbatim (confirmed live: retrying a phone
|
||
// number that's already registered). CTRL_ERROR_TEXT_CN already translates this same class
|
||
// of {ctrl} error for login; reused here with a registration-appropriate fallback instead
|
||
// of loginErrorMessage's own (its 401/403 fallback is "账号或密码错误", which doesn't fit a
|
||
// failed registration).
|
||
const known = translateCtrlError(err);
|
||
this.handleError(known || (err && err.message) || '注册失败,请稍后重试', 'err');
|
||
});
|
||
}
|
||
|
||
// Registration via the product's username/phone flow (chapter 1.4). `extra` carries the fields the
|
||
// server added to {acc} (username/phone/invite_code, see server/user.go resolveRegistrationAccount) --
|
||
// the SDK has no API for them, so they're injected by the sendText patch installed in tnSetup().
|
||
// `cred_` (when phone was verified through POST /v1/im/auth/verification-codes) is
|
||
// {meth:'tel', val, resp: verification_token, params: {challenge_id}} -- see server/user.go
|
||
// consumeProductVerificationCred, which validates resp/params against the REST-issued token.
|
||
//
|
||
// `login_` must equal what the server's own resolveRegistrationAccount will compute as the
|
||
// account name, or replyCreateUser rejects the whole request with a policy violation before it
|
||
// even looks at username/phone/cred: for phone registration that's the E.164 phone (not blank --
|
||
// registrationSecretMatches requires a non-empty match), for username registration it's the
|
||
// username itself.
|
||
handleImRegisterRequest(login_, password_, displayName, extra, cred_) {
|
||
this.tinode.__pendingAccExtra = extra;
|
||
this.handleNewAccountRequest(login_, password_, theCard(displayName, null), cred_);
|
||
}
|
||
|
||
handleToggleIncognitoMode(on) {
|
||
// Make state undefined.
|
||
this.setState({incognitoMode: null});
|
||
|
||
const me = this.tinode.getMeTopic();
|
||
const am = me.getAccessMode().updateWant(on ? '-P' : '+P').getWant();
|
||
me.setMeta({sub: {mode: am}}).catch(err => {
|
||
// Request failed, keep existing state.
|
||
this.setState({incognitoMode: !on});
|
||
this.handleError(err.message, 'err');
|
||
});
|
||
}
|
||
|
||
// Manual change of color scheme: 'dark', 'light', 'auto'.
|
||
handleChangeColorSchema(schema) {
|
||
this.setState({colorSchema: schema});
|
||
LocalStorageUtil.updateObject('settings', {colorSchema: schema});
|
||
document.documentElement.style.colorScheme = schema == 'auto' ? 'light dark' : schema;
|
||
this.applyColorSchema(schema, this.state.systemColorSchema, this.state.wallpaperSize);
|
||
}
|
||
|
||
// System color scheme has changed.
|
||
handleColorSchemeChange(event) {
|
||
const systemSchema = event.matches ? 'dark' : 'light';
|
||
this.setState({systemColorSchema: systemSchema});
|
||
this.applyColorSchema(this.state.colorSchema, systemSchema, this.state.wallpaperSize);
|
||
}
|
||
|
||
applyColorSchema(schema, systemSchema, size) {
|
||
const effectiveSchema = schema == 'auto' ? systemSchema : schema;
|
||
document.documentElement.style.setProperty('--wallpaper-invert', effectiveSchema == 'dark' && size ? '1' : '0');
|
||
document.documentElement.style.setProperty('--wallpaper-brightness', effectiveSchema == 'dark' && !size ? '0.5' : '1');
|
||
}
|
||
|
||
handleSelectWallpapers() {
|
||
this.handleError();
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'wallpapers'));
|
||
}
|
||
|
||
applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) {
|
||
document.documentElement.style.setProperty('--wallpaper-url', `url('${wallpaper}')`);
|
||
document.documentElement.style.setProperty('--wallpaper-repeat', size ? 'repeat' : 'no-repeat');
|
||
document.documentElement.style.setProperty('--wallpaper-blur', size ? '0px' : `${blur}px`);
|
||
document.documentElement.style.setProperty('--wallpaper-size', size ? `${size}px` : 'cover');
|
||
document.documentElement.style.setProperty('--wallpaper-position', size ? 'unset' : 'center');
|
||
document.documentElement.style.setProperty('--wallpaper-invert', effectiveSchema == 'dark' && size ? '1' : '0');
|
||
document.documentElement.style.setProperty('--wallpaper-brightness', effectiveSchema == 'dark' && !size ? '0.5' : '1');
|
||
}
|
||
|
||
handleWallpaperSelected(wallpaper, size, blur) {
|
||
this.handleError();
|
||
LocalStorageUtil.updateObject('settings', {wallpaper: wallpaper, wallpaperSize: size, wallpaperBlur: blur});
|
||
this.setState({wallpaper: wallpaper, wallpaperSize: size, wallpaperBlur: blur});
|
||
this.applyWallpaperSettings(wallpaper, size, blur,
|
||
this.state.colorSchema == 'auto' ? this.state.systemColorSchema : this.state.colorSchema);
|
||
}
|
||
|
||
handleChangeTextSize(size) {
|
||
this.setState({textSize: size | 0});
|
||
LocalStorageUtil.updateObject('settings', {textSize: size});
|
||
document.documentElement.style.setProperty('--message-text-size', `${size}pt`);
|
||
}
|
||
|
||
handleSendOnEnter(option) {
|
||
this.setState({sendOnEnter: option});
|
||
LocalStorageUtil.updateObject('settings', {sendOnEnter: option});
|
||
}
|
||
|
||
handleUpdateAccountTagsRequest(_, tags) {
|
||
this.tinode.getMeTopic().setMeta({tags: tags})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
// User chose Settings menu item.
|
||
handleSettings() {
|
||
this.handleError();
|
||
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
|
||
this.state.myUserId ? 'edit' : 'settings'));
|
||
}
|
||
|
||
// User updated global parameters.
|
||
handleGlobalSettings(settings) {
|
||
const serverAddress = settings.serverAddress || this.state.serverAddress;
|
||
const transport = settings.transport || this.state.transport;
|
||
const secureConnection = settings.secureConnection === undefined ?
|
||
this.state.secureConnection : settings.secureConnection;
|
||
if (this.tinode) {
|
||
this.tinode.clearStorage();
|
||
this.tinode.onDisconnect = undefined;
|
||
this.tinode.disconnect();
|
||
}
|
||
this.tinode = TinodeWeb.tnSetup(serverAddress, secureConnection, transport,
|
||
this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'));
|
||
this.tinode.onConnect = this.handleConnected;
|
||
this.tinode.onDisconnect = this.handleDisconnect;
|
||
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
|
||
this.tinode.onInfoMessage = this.handleInfoMessage;
|
||
this.tinode.onDataMessage = this.handleDataMessage;
|
||
|
||
this.setState({
|
||
serverAddress: serverAddress,
|
||
transport: transport,
|
||
secureConnection: secureConnection,
|
||
});
|
||
LocalStorageUtil.setObject('settings', {
|
||
serverAddress: serverAddress,
|
||
transport: transport,
|
||
secureConnection: secureConnection,
|
||
});
|
||
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, ''));
|
||
}
|
||
|
||
// Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry).
|
||
// Persists the tenant context (read by im-api.js and by the {hi}-patching in tnSetup) and, if the
|
||
// resolved tenant genuinely lives on a different host, rebuilds the Tinode client to point there.
|
||
handleTenantResolved(data, requestedCode) {
|
||
const tenant = data.tenant || {};
|
||
const endpoints = data.endpoints || {};
|
||
LocalStorageUtil.setObject('im-tenant', {
|
||
code: tenant.code || requestedCode,
|
||
name: tenant.name || tenant.code || requestedCode,
|
||
endpoints
|
||
});
|
||
|
||
let host = this.state.serverAddress;
|
||
let secureConnection = this.state.secureConnection;
|
||
if (endpoints.websocket) {
|
||
try {
|
||
const wsUrl = new URL(endpoints.websocket);
|
||
host = wsUrl.host;
|
||
secureConnection = wsUrl.protocol === 'wss:';
|
||
} catch (e) {
|
||
// Malformed endpoint; keep the current host/scheme.
|
||
}
|
||
}
|
||
if (host && (host !== this.state.serverAddress || secureConnection !== this.state.secureConnection)) {
|
||
this.handleGlobalSettings({serverAddress: host, secureConnection});
|
||
}
|
||
// Else: same host/scheme, nothing to reconnect -- the sendText patch installed in tnSetup() reads
|
||
// the tenant context fresh on every {hi}, so the newly-persisted code takes effect immediately.
|
||
}
|
||
|
||
// User chose 'Archived chats'.
|
||
handleShowArchive() {
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
|
||
this.state.myUserId ? 'archive' : ''));
|
||
}
|
||
|
||
// User viewes 'Blocked chats'.
|
||
handleShowBlocked() {
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
|
||
this.state.myUserId ? 'blocked' : ''));
|
||
}
|
||
|
||
toggleFCMToken(enabled) {
|
||
if (enabled) {
|
||
this.setState({desktopAlerts: null});
|
||
if (!this.state.firebaseToken) {
|
||
this.initFCMessaging();
|
||
} else {
|
||
this.setState({desktopAlerts: true});
|
||
if (LocalStorageUtil.getObject('keep-logged-in')) {
|
||
LocalStorageUtil.updateObject('settings', {desktopAlerts: true});
|
||
}
|
||
}
|
||
} else if (this.state.firebaseToken && this.fcm) {
|
||
firebaseDelToken(this.fcm).catch(err => {
|
||
console.error("Unable to delete token.", err);
|
||
}).finally(_ => {
|
||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||
localStorage.removeItem('firebase-token');
|
||
this.setState({desktopAlerts: false, firebaseToken: null});
|
||
// Inform the server that the token was deleted.
|
||
this.tinode.setDeviceToken(null);
|
||
});
|
||
} else {
|
||
this.setState({desktopAlerts: false, firebaseToken: null});
|
||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||
}
|
||
}
|
||
|
||
handleToggleMessageSounds(enabled) {
|
||
this.setState({messageSounds: enabled});
|
||
LocalStorageUtil.updateObject('settings', {
|
||
messageSoundsOff: !enabled
|
||
});
|
||
}
|
||
|
||
handleCredAdd(method, value) {
|
||
const me = this.tinode.getMeTopic();
|
||
me.setMeta({cred: {meth: method, val: value}})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
handleCredDelete(method, value) {
|
||
const me = this.tinode.getMeTopic();
|
||
me.delCredential(method, value)
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
handleCredConfirm(method, response) {
|
||
TinodeWeb.navigateToCredentialsView({cred: [method], code: response});
|
||
}
|
||
|
||
// User clicked Cancel button in Setting or Sign Up panel.
|
||
handleSidepanelCancel() {
|
||
const parsed = HashNavigation.parseUrlHash(window.location.hash);
|
||
let path = '';
|
||
if (['security','support','general','notif','acc_general'].includes(parsed.path[0])) {
|
||
path = 'edit';
|
||
} else if ('crop' == parsed.path[0]) {
|
||
path = 'general';
|
||
} else if ('blocked' == parsed.path[0]) {
|
||
path = 'security';
|
||
} else if ('wallpapers' == parsed.path[0]) {
|
||
path = 'acc_general';
|
||
} else if (this.state.myUserId) {
|
||
path = 'contacts';
|
||
}
|
||
parsed.path[0] = path;
|
||
if (parsed.params) {
|
||
delete parsed.params.code;
|
||
delete parsed.params.method;
|
||
delete parsed.params.tab;
|
||
delete parsed.params.scheme;
|
||
delete parsed.params.token;
|
||
}
|
||
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
|
||
this.setState({errorText: '', errorLevel: null});
|
||
}
|
||
|
||
// Sidepanel navigator. No need to bind to 'this'.
|
||
basicNavigator(hash) {
|
||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
|
||
}
|
||
|
||
// Topic info navigator. No need to bind to 'this'.
|
||
infoNavigator(hash) {
|
||
HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash));
|
||
}
|
||
|
||
// Request to start a topic, new or selected from search results, or "by ID".
|
||
handleStartTopicRequest(topicName, newTopicParams, isChannel) {
|
||
// Check if topic is indeed new. If not, launch it.
|
||
if (topicName && this.tinode.isTopicCached(topicName)) {
|
||
this.handleTopicSelected(topicName);
|
||
return;
|
||
}
|
||
|
||
const params = {};
|
||
if (Tinode.isP2PTopicName(topicName)) {
|
||
// Because we are initiating the subscription, set 'want' to all permissions.
|
||
params.sub = {mode: DEFAULT_P2P_ACCESS_MODE};
|
||
// Give the other user all permissions too.
|
||
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
|
||
} else {
|
||
topicName = topicName || this.tinode.newGroupTopicName(isChannel);
|
||
if (newTopicParams) {
|
||
params.desc = {public: newTopicParams.public, private: {comment: newTopicParams.private}};
|
||
params.tags = newTopicParams.tags;
|
||
}
|
||
}
|
||
params._topicName = topicName;
|
||
this.setState({newTopicParams: params}, _ => {this.handleTopicSelected(topicName)});
|
||
}
|
||
|
||
// New topic was created, here is the new topic name.
|
||
handleNewTopicCreated(oldName, newName) {
|
||
let nextState = {};
|
||
if (this.state.callShouldStart) {
|
||
nextState = {callState: CALL_STATE_IN_PROGRESS, callShouldStart: false};
|
||
}
|
||
if (this.state.topicSelected == oldName && oldName != newName) {
|
||
// If the current URl contains the old topic name, replace it with new.
|
||
// Update the name of the selected topic first so the navigator doen't clear
|
||
// the state.
|
||
nextState.topicSelected = newName;
|
||
}
|
||
this.setState(nextState, _ => {
|
||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName));
|
||
});
|
||
}
|
||
|
||
handleTopicUpdateRequest(topicName, pub, priv, defacs) {
|
||
this.handleError();
|
||
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (topic) {
|
||
const params = {};
|
||
let attachments;
|
||
if (pub) {
|
||
if (pub.photo) {
|
||
if (pub.photo.ref && pub.photo.ref != Tinode.DEL_CHAR) {
|
||
attachments = [pub.photo.ref];
|
||
} else if (!pub.photo.data || pub.photo.data == Tinode.DEL_CHAR) {
|
||
pub.photo = Tinode.DEL_CHAR;
|
||
}
|
||
}
|
||
params.public = pub;
|
||
}
|
||
|
||
if (typeof priv == 'string') {
|
||
params.private = (priv === Tinode.DEL_CHAR) ?
|
||
Tinode.DEL_CHAR : {comment: priv};
|
||
}
|
||
if (defacs) {
|
||
params.defacs = defacs;
|
||
}
|
||
topic.setMeta({desc: params, attachments: attachments})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
}
|
||
|
||
handleUnarchive(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (topic) {
|
||
topic.archive(false).catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
}
|
||
|
||
handleUpdatePasswordRequest(password) {
|
||
this.handleError();
|
||
|
||
if (!password) {
|
||
return Promise.reject(new Error('密码不能为空'));
|
||
}
|
||
// 必须把 Promise 返回出去:调用方(personal-settings-view 的修改密码页)要靠它判断成败。
|
||
// 原来这里只在内部 catch 掉、什么都不返回,调用方拿到 undefined,于是密码策略被服务端拒绝时
|
||
// 用户照样看到"密码已更新"并被跳走。跟之前修过的 handleDeleteAccount 是同一类问题。
|
||
return this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password)
|
||
.catch(err => {
|
||
this.handleError(err.message, 'err');
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
handleChangePermissions(topicName, mode, uid) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (topic) {
|
||
const am = topic.getAccessMode();
|
||
if (uid) {
|
||
am.updateGiven(mode);
|
||
mode = am.getGiven();
|
||
} else {
|
||
am.updateWant(mode);
|
||
mode = am.getWant();
|
||
}
|
||
topic.setMeta({sub: {user: uid, mode: mode}})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
}
|
||
|
||
handleTagsUpdateRequest(topicName, tags) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (topic) {
|
||
topic.setMeta({tags: tags})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
}
|
||
|
||
handleLogout() {
|
||
updateFavicon(0);
|
||
|
||
// Remove stored data.
|
||
localStorage.removeItem('auth-token');
|
||
localStorage.removeItem('firebase-token');
|
||
localStorage.removeItem('settings');
|
||
sessionStorage.removeItem('im-session-token');
|
||
if (this.state.firebaseToken && this.fcm) {
|
||
// Unsubscribe failures (e.g. 403 token-unsubscribe-failed) should not block logout.
|
||
firebaseDelToken(this.fcm).catch(err => {
|
||
console.warn('Failed to delete FCM token on logout', err);
|
||
});
|
||
}
|
||
|
||
// Reset color scheme.
|
||
document.documentElement.style.colorScheme = DEFAULT_COLOR_SCHEME == 'auto' ? 'light dark' : DEFAULT_COLOR_SCHEME;
|
||
document.documentElement.style.setProperty('--message-text-size', `${DEFAULT_TEXT_SIZE}pt`);
|
||
|
||
clearInterval(this.reconnectCountdown);
|
||
|
||
let cleared;
|
||
if (this.tinode) {
|
||
cleared = this.tinode.clearStorage();
|
||
this.tinode.onDisconnect = undefined;
|
||
this.tinode.disconnect();
|
||
} else {
|
||
cleared = Promise.resolve();
|
||
}
|
||
this.setState(this.getBlankState());
|
||
|
||
cleared.then(_ => {
|
||
// Same fix as the initial tnSetup call in componentDidMount: use the resolved
|
||
// state.secureConnection (settings-aware), not a raw isSecureConnection() page-protocol check.
|
||
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection,
|
||
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), _ => {
|
||
this.tinode.onConnect = this.handleConnected;
|
||
this.tinode.onDisconnect = this.handleDisconnect;
|
||
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
|
||
this.tinode.onInfoMessage = this.handleInfoMessage;
|
||
this.tinode.onDataMessage = this.handleDataMessage;
|
||
HashNavigation.navigateTo('');
|
||
})
|
||
});
|
||
}
|
||
|
||
handleDeleteAccount() {
|
||
return this.tinode.delCurrentUser(true).then(_ => {
|
||
this.handleLogout();
|
||
});
|
||
}
|
||
|
||
handleDeleteTopicRequest(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic) {
|
||
return;
|
||
}
|
||
|
||
// Request to hard-delete topic.
|
||
topic.delTopic(true).then(_ => {
|
||
// Hide MessagesView and InfoView panels.
|
||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||
}).catch(err => {
|
||
this.handleError(err.message, 'err');
|
||
});
|
||
}
|
||
|
||
handleDeleteMessagesRequest(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic) {
|
||
return;
|
||
}
|
||
|
||
// Request hard-delete all messages.
|
||
topic.delMessagesAll(true)
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
handleLeaveUnsubRequest(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic) {
|
||
return;
|
||
}
|
||
|
||
topic.leave(true).then(_ => {
|
||
// Hide MessagesView and InfoView panels.
|
||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||
}).catch(err => {
|
||
this.handleError(err.message, 'err');
|
||
});
|
||
}
|
||
|
||
handleBlockTopicRequest(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic) {
|
||
return;
|
||
}
|
||
|
||
topic.updateMode(null, '-JP')
|
||
.then(_ => {
|
||
// Hide MessagesView and InfoView panels.
|
||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||
})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
handleReportTopic(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic) {
|
||
return;
|
||
}
|
||
|
||
// The native `this.tinode.report('report', topicName)` call (WS pub to the "sys" topic) requires
|
||
// auth.LevelRoot server-side (server/session.go) which a normal logged-in user never has -- it
|
||
// silently fails permission checks and never reaches the abuse-report tables. Route to the real
|
||
// REST reporting flow (im-api.js's getAbuseReasons/submitAbuseReport, backed by POST /reports/abuse)
|
||
// instead of the old dead-end call.
|
||
const isP2P = topic.isP2PType();
|
||
const targetName = topicDisplayName(topicName, topic.public, topicName);
|
||
const params = isP2P
|
||
? {target_type: 'user', target_id: topicName, target_name: targetName}
|
||
: {target_type: 'group', target_id: topicName, topic: topicName, target_name: targetName};
|
||
|
||
// Remove J and P permissions (the "block" half of "block and report").
|
||
topic.updateMode(null, '-JP')
|
||
.then(_ => {
|
||
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
|
||
hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons');
|
||
Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); });
|
||
HashNavigation.navigateTo(hash);
|
||
})
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
}
|
||
|
||
handleShowContextMenu(params, menuItems) {
|
||
this.setState({
|
||
contextMenuVisible: true,
|
||
contextMenuClickAt: {x: params.x, y: params.y},
|
||
contextMenuParams: params,
|
||
contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
|
||
contextMenuBounds: this.selfRef.current.getBoundingClientRect()
|
||
});
|
||
}
|
||
|
||
//
|
||
handleShowForwardDialog(params) {
|
||
if (this.state.sidePanelSelected == 'newtpk') {
|
||
// If the Find panel is active, close it.
|
||
this.handleSidepanelCancel();
|
||
}
|
||
const header = params.userName ? '➦ ' + params.userName : '';
|
||
const content = typeof params.content == 'string' ? Drafty.init(params.content) : Drafty.forwardedContent(params.content);
|
||
const preview = Drafty.preview(content, FORWARDED_PREVIEW_LENGTH, true);
|
||
const msg = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, params.userFrom)), content);
|
||
const msgPreview = Drafty.quote(header, params.userFrom, preview);
|
||
|
||
const head = {
|
||
forwarded: params.topicName + ':' + params.seq
|
||
};
|
||
this.setState({
|
||
forwardDialogVisible: true,
|
||
forwardMessage: { head: head, msg: msg, preview: msgPreview }
|
||
});
|
||
}
|
||
|
||
handleShareTheCard(card) {
|
||
this.setState({
|
||
forwardDialogVisible: true,
|
||
forwardMessage: {
|
||
content: card,
|
||
type: card.contentType,
|
||
}
|
||
});
|
||
}
|
||
|
||
// Extracted out of defaultTopicContextMenu so the new 4.9-4.14 attachment-panel call shortcuts
|
||
// (send-message.jsx) can gate on exactly the same real-world availability check as the existing
|
||
// "..." menu items, instead of re-deriving (and risking drifting from) the same three conditions.
|
||
canStartCall(topicName) {
|
||
// Never checked whether a call was already in progress -- the "..." menu entry and
|
||
// send-message.jsx's attachment-panel shortcut stayed clickable for the whole (very normal,
|
||
// product-supported) duration of a minimized call. A second click re-arms
|
||
// handleStartVideoCall/handleStartAudioCall's 60s ring timeout and forces callState back to
|
||
// CALL_STATE_OUTGOING_INITATED with no visible effect (the already-mounted CallPanel doesn't
|
||
// react to that prop), silently hanging up the real, healthy call a full minute later.
|
||
if (this.state.callState != CALL_STATE_NONE) {
|
||
return false;
|
||
}
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic || topic._deleted || !Tinode.isP2PTopicName(topicName)) {
|
||
return false;
|
||
}
|
||
const acs = topic.getAccessMode();
|
||
const writer = !!(acs && acs.isWriter());
|
||
const cachedBootstrap = this.state.myUserId ? readCachedData('bootstrap:' + this.state.myUserId) : null;
|
||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features ||
|
||
(cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false);
|
||
return writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||
}
|
||
|
||
defaultTopicContextMenu(topicName) {
|
||
const topic = this.tinode.getTopic(topicName);
|
||
|
||
if (topic._deleted) {
|
||
return [
|
||
'topic_delete'
|
||
];
|
||
}
|
||
|
||
const me = this.tinode.getMeTopic();
|
||
let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false,
|
||
archived = false, pinned = false, webrtc = false, p2p = false, self = false;
|
||
if (topic) {
|
||
subscribed = topic.isSubscribed();
|
||
archived = topic.isArchived();
|
||
pinned = me.pinnedTopicRank(topicName) > 0;
|
||
|
||
const acs = topic.getAccessMode();
|
||
if (acs) {
|
||
muted = acs.isMuted();
|
||
blocked = !acs.isJoiner();
|
||
self_blocked = !acs.isJoiner('want');
|
||
deleter = acs.isDeleter();
|
||
}
|
||
}
|
||
|
||
// 后端今天新增了一个全局(非按租户)的通话总开关:{pub head:webrtc:...} 现在会先过
|
||
// im_client_feature 这个开关,关闭时整条消息直接被服务端拒绝。这个开关缺失/查询失败时是
|
||
// fail-closed(当成关闭),比 Tinode 协议层的 iceServers 配置更容易出现"某天突然打不通"的
|
||
// 情况。canStartCall 统一做这几条判断(也是 4.9-4.14 附件面板通话入口在用的同一份逻辑),
|
||
// 按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,这里不做的话用户点了
|
||
// 按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。
|
||
webrtc = this.canStartCall(topicName);
|
||
p2p = Tinode.isP2PTopicName(topicName);
|
||
self = Tinode.isSelfTopicName(topicName);
|
||
|
||
return [
|
||
subscribed ? {
|
||
title: this.props.intl.formatMessage(messages.menu_item_info),
|
||
handler: this.handleShowInfoView
|
||
} : null,
|
||
subscribed && p2p && webrtc ? {
|
||
title: this.props.intl.formatMessage(messages.menu_item_audio_call),
|
||
handler: this.handleStartAudioCall
|
||
} : null,
|
||
subscribed && p2p && webrtc ? {
|
||
title: this.props.intl.formatMessage(messages.menu_item_video_call),
|
||
handler: this.handleStartVideoCall
|
||
} : null,
|
||
subscribed ? 'messages_clear' : null,
|
||
subscribed && deleter && !self ? 'messages_clear_hard' : null,
|
||
self ? null : (muted ? (blocked ? null : 'topic_unmute') : 'topic_mute'),
|
||
self ? null : (self_blocked ? 'topic_unblock' : 'topic_block'),
|
||
pinned ? 'topic_unpin' : 'topic_pin',
|
||
archived ? 'topic_restore' : 'topic_archive',
|
||
'topic_delete'
|
||
];
|
||
}
|
||
|
||
handleHideContextMenu() {
|
||
this.setState({
|
||
contextMenuVisible: false,
|
||
contextMenuClickAt: null,
|
||
contextMenuParams: null,
|
||
contextMenuBounds: null
|
||
});
|
||
}
|
||
|
||
handleHideForwardDialog(keepForwardedMessage) {
|
||
this.setState({
|
||
forwardDialogVisible: false,
|
||
forwardMessage: keepForwardedMessage ? this.state.forwardMessage : null
|
||
});
|
||
}
|
||
|
||
handleContextMenuAction(action, promise, params) {
|
||
if (action == 'topic_archive') {
|
||
if (promise && params.topicName && params.topicName == this.state.topicSelected) {
|
||
promise.then(_ => {
|
||
this.handleTopicSelected(null);
|
||
});
|
||
}
|
||
} else if (action == 'menu_item_forward') {
|
||
this.handleShowForwardDialog(params);
|
||
} else if (action == 'menu_item_select') {
|
||
this.handleEnterSelectMode(params);
|
||
}
|
||
}
|
||
|
||
// Ch.4 multi-select: pre-selects the message that was long-pressed to reach "多选".
|
||
handleEnterSelectMode(params) {
|
||
this.setState({
|
||
selectMode: true,
|
||
selectModeTopic: params.topicName,
|
||
selectedSeqs: [params.seq]
|
||
});
|
||
}
|
||
|
||
handleToggleMessageSelect(seq) {
|
||
const selectedSeqs = this.state.selectedSeqs.includes(seq) ?
|
||
this.state.selectedSeqs.filter(s => s != seq) :
|
||
this.state.selectedSeqs.concat(seq);
|
||
this.setState({selectedSeqs});
|
||
}
|
||
|
||
handleExitSelectMode() {
|
||
this.setState({selectMode: false, selectModeTopic: null, selectedSeqs: [], batchForwardVisible: false});
|
||
}
|
||
|
||
handleShowBatchForward() {
|
||
this.setState({batchForwardVisible: true});
|
||
}
|
||
|
||
handleHideBatchForward() {
|
||
this.setState({batchForwardVisible: false});
|
||
}
|
||
|
||
handleOpenScanner() {
|
||
this.setState({scannerOpen: true});
|
||
}
|
||
|
||
handleCloseScanner() {
|
||
this.setState({scannerOpen: false});
|
||
}
|
||
|
||
// `text` is whatever jsQR decoded from the camera frame -- only codes this app itself generated
|
||
// (createMyQRCode/createGroupQRCode, both `im://qr?token=...&purpose=...`) resolve to anything;
|
||
// a QR code from the outside world (a URL, a WiFi code) is reported as unrecognized rather than
|
||
// silently failing the resolve call with a confusing server error.
|
||
handleQrDetected(text) {
|
||
const hasScheme = typeof text === 'string' && text.indexOf('im://qr') === 0;
|
||
const params = hasScheme ? new URLSearchParams(text.slice(text.indexOf('?') + 1)) : null;
|
||
const token = params ? params.get('token') : null;
|
||
const purpose = params ? params.get('purpose') : null;
|
||
if (!token) {
|
||
this.setState({scannerOpen: false});
|
||
this.handleError('无法识别的二维码', 'err');
|
||
return;
|
||
}
|
||
resolveQRCode(token, purpose).then(data => {
|
||
if (data.purpose === 'join_group' && data.group) {
|
||
if (data.status === 'joined') {
|
||
this.setState({scannerOpen: false});
|
||
this.handleTopicSelected(data.group.topic);
|
||
} else {
|
||
this.setState({scannerOpen: false, groupJoinPreview: {...data.group, qrToken: token}});
|
||
}
|
||
return;
|
||
}
|
||
if (data.user) {
|
||
this.setState({scannerOpen: false});
|
||
// The profile page this navigates to re-fetches via GET /users/{id}/profile, which 403s for
|
||
// anyone who isn't already a friend (server-side privacy default) -- but resolveQRCode's
|
||
// response already carries the same fields unconditionally, so stash it under the same
|
||
// cache key contact-management-view.jsx's Profile/Verification read on mount, the same fix
|
||
// applied there for the account-search entry point into this identical dead end.
|
||
writeCachedData('profile-seed:' + data.user.user_id, {
|
||
...data.user,
|
||
capabilities: {can_add_friend: !!data.user.can_add}
|
||
});
|
||
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
|
||
hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
|
||
hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id);
|
||
HashNavigation.navigateTo(hash);
|
||
return;
|
||
}
|
||
this.setState({scannerOpen: false});
|
||
this.handleError('无法识别的二维码', 'err');
|
||
}).catch(err => {
|
||
this.setState({scannerOpen: false});
|
||
this.handleError(err.message || '二维码已失效', 'err');
|
||
});
|
||
}
|
||
|
||
handleSubmitGroupJoinRequest(message) {
|
||
const preview = this.state.groupJoinPreview;
|
||
if (!preview) {
|
||
return Promise.resolve();
|
||
}
|
||
return createGroupJoinRequest(preview.topic, {message, source: 'qr', qrToken: preview.qrToken}).then(() => {
|
||
this.setState({groupJoinPreview: null});
|
||
this.handleError('申请已提交,等待管理员通过', 'info');
|
||
}).catch(err => {
|
||
this.handleError(err.message || '申请提交失败', 'err');
|
||
});
|
||
}
|
||
|
||
handleCancelGroupJoinPreview() {
|
||
this.setState({groupJoinPreview: null});
|
||
}
|
||
|
||
handleShowAlert(title, content, onConfirm, confirmText, onReject, rejectText) {
|
||
this.setState({
|
||
alertVisible: true,
|
||
alertParams: {
|
||
title: title,
|
||
content: content,
|
||
onConfirm: onConfirm,
|
||
confirm: confirmText,
|
||
onReject: onReject,
|
||
reject: rejectText
|
||
}
|
||
});
|
||
}
|
||
|
||
// The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx)
|
||
// -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx
|
||
// "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across
|
||
// Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached
|
||
// from an open chat's own header -- every test of them this engagement typed the hash directly.
|
||
// InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed
|
||
// parent per panel and never used real browser history, so routing here fixed both problems at once
|
||
// rather than teaching the old view a second back-navigation scheme it's slated for removal anyway.
|
||
handleShowInfoView() {
|
||
const topicName = this.state.topicSelected;
|
||
if (Tinode.isP2PTopicName(topicName)) {
|
||
// Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds
|
||
// the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current
|
||
// hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
|
||
// friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a
|
||
// fresh topic selection, blanking the whole view -- the exact bug already found and fixed for
|
||
// handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here.
|
||
let hash = HashNavigation.setUrlSidePanel('', 'friend-settings');
|
||
hash = HashNavigation.addUrlParam(hash, 'uid', topicName);
|
||
HashNavigation.navigateTo(hash);
|
||
return;
|
||
}
|
||
if (!Tinode.isSelfTopicName(topicName)) {
|
||
let hash = HashNavigation.setUrlSidePanel('', 'group-settings');
|
||
hash = HashNavigation.addUrlParam(hash, 'topic', topicName);
|
||
HashNavigation.navigateTo(hash);
|
||
return;
|
||
}
|
||
// "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it.
|
||
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
|
||
this.setState({infoPanel: 'info'});
|
||
}
|
||
|
||
handleMemberUpdateRequest(topicName, added, removed) {
|
||
if (!topicName) {
|
||
return;
|
||
}
|
||
|
||
const topic = this.tinode.getTopic(topicName);
|
||
if (!topic) {
|
||
return;
|
||
}
|
||
|
||
if (added && added.length > 0) {
|
||
added.forEach(uid => {
|
||
topic.invite(uid, null)
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
});
|
||
}
|
||
|
||
if (removed && removed.length > 0) {
|
||
removed.forEach(uid => {
|
||
topic.delSubscription(uid)
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
});
|
||
}
|
||
}
|
||
|
||
handleValidateCredentialsRequest(cred, code, token) {
|
||
if (this.tinode.isAuthenticated()) {
|
||
// Adding new email or phone number in account setting.
|
||
this.tinode.getMeTopic().setMeta({cred: {meth: cred, resp: code}})
|
||
.then(_ => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'contacts')))
|
||
.catch(err => this.handleError(err.message, 'err'));
|
||
} else {
|
||
// Credential validation on signup.
|
||
this.setState({credMethod: cred, credCode: code, credToken: token});
|
||
this.doLogin(null, null, token, {meth: cred, resp: code});
|
||
}
|
||
}
|
||
|
||
handlePasswordResetRequest(method, value) {
|
||
// If already connected, connnect() will return a resolved promise.
|
||
return this.tinode.connect()
|
||
.then(_ => this.tinode.requestResetAuthSecret('basic', method, value))
|
||
.catch(err => {
|
||
// Socket error
|
||
this.handleError(err.message, 'err');
|
||
});
|
||
}
|
||
|
||
handleResetPassword(newPassword, tempAuth) {
|
||
const secret = base64ReEncode(tempAuth.secret);
|
||
if (!secret || !tempAuth.scheme) {
|
||
this.handleError(this.props.intl.formatMessage(messages.invalid_security_token), 'err');
|
||
} else {
|
||
this.tinode.connect()
|
||
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {scheme: tempAuth.scheme, secret: secret}))
|
||
.then(_ => {
|
||
this.handleError(this.props.intl.formatMessage(messages.password_reset_success), 'info');
|
||
HashNavigation.navigateTo('');
|
||
})
|
||
.catch(err => {
|
||
this.handleError(err.message, 'err');
|
||
});
|
||
}
|
||
}
|
||
|
||
handleShowCountrySelector(code, dial, selectedCallback) {
|
||
this.handleShowAlert(this.props.intl.formatMessage(messages.select_country),
|
||
<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);
|