Files
im_webapp/src/views/tinode-web.jsx
T
Your NameandClaude Sonnet 5 4a30cfa6b3 第四轮深度测试:修复7个agent发现的问题,重点是编辑态附件覆盖的完整旁路和通话面板崩溃防护
再次派7个agent覆盖全部11章,验证第三轮修复+找新问题。本次委托agent专门交叉核对
上一轮每一项修复是否真的生效,多数确认有效,但抓出2处"看似修好、实则有旁路"的情况
和若干新问题。

**编辑消息+拖放文件仍可静默覆盖被编辑消息(上一轮修复的完整旁路,Ch3-4 agent发现)**
上一轮给`onAttachFile`/`onAttachImage`/`onAttachAudio`三个prop加了editingMessage门禁,
但`isDragEnabled()`(messages-view.jsx)没有检查这个状态——拖放文件到聊天窗口走的是
`handleFileDrop`直接调用`handleAttachImageOrVideo`/`handleAttachFile`类方法本身,完全
绕开了被置null的props。编辑态下不点"+"号、直接把文件拖进聊天窗口,依然会静默替换掉
被编辑消息的原文。已给`isDragEnabled()`补上同款判断,这是`handleDrop`和渲染层拖放提示
两处调用的唯一门禁函数,一次性堵住。

同一个agent还发现编辑态下点击"回复"另一条消息会静默放弃当前编辑(`handlePickReply`
覆盖`this.state.reply`但不清`contentToEdit`,发送时值走了`reply`分支而不是`editing`
分支,编辑框里的原文被当成对另一条消息的新回复发出去,原消息未被修改)——已在
`handleShowMessageContextMenu`里比照`menu_item_edit`的年龄过滤模式,编辑态下直接不
提供"回复"这个菜单项。

**实名认证驳回提醒的dismissKey修复上一轮实际没生效(Ch1-2 agent发现,已重新修复)**
上一轮把dismissKey从纯status改成"submission_id或status",但live验证发现
`bootstrap.security.realname`这个数据源根本不包含submission_id字段(后端serveIMBootstrap
的实现只有status/required/allow_skip/remind_after四个硬编码字段)——所以这个修复在
真实环境下永远退化回status,等于没有变化,两次独立驳回依然会互相误压制提醒。这次改为
额外调用专用的`GET /security/realname`接口(该接口的实现确实会返回submission_id),
用它的数据驱动dismissKey和提醒触发条件,bootstrap的字段只保留用于remind_after/
allow_skip这些它独有的字段。已用真实账号验证dismissKey现在正确从这个新数据源生成。

**呼叫面板未卸载防护补全到call-panel.jsx(Ch8 agent发现,同类问题上一轮只修了
audio-recorder.jsx)**
`start()`/`unmuteVideo()`/`handleSwitchCameraClick()`三处的getUserMedia/
enumerateDevices异步链,结尾都会touch `this.localRef.current`/`this.state.localStream`,
但都没检查组件是否已经卸载——权限弹窗未决出结果时通话结束(呼出超时/收到挂断/对方
主动挂断),这些promise resolve后会在死实例上抛TypeError,被各自的catch吞掉后又会展示
一句过期的原始英文报错。三处都补了`this._unmounted`检查,拿到的媒体流/轨道直接停止
而不是继续使用。

**其余修复**:`call-message.jsx`补上和`call-status.jsx`一致的"进行中"状态文案(原来
通话进行中且消息还没有最终时长时显示"0:00"而不是"进行中",已用真实渲染验证过两个
组件行为不一致);`tinode-web.jsx`的`handleCallClose`补上重置`callSeq`(避免跨通话
残留旧值)+ 更新`armCallRingTimeout`注释准确描述它是后端30秒建立超时的二级兜底而非
主要防线(后端`call_establishment_timeout`默认30秒,已有独立机制处理"直接不接"的
常见场景);`REASON_MESSAGES`补充`API_KEY_REQUIRED`/`METHOD_NOT_ALLOWED`(此前记录过
一次真实的部署期API Key配置漂移事故,属于这两个reason的真实触发场景);
`message-center-view.jsx`的`NOTIFICATION_TEXT`补充风险确认"取消"结果的文案(发消息
前风险提示点"取消发送"会产生这个系统通知,此前只收录了"继续");会话列表"置顶"从
只变背景色改为真正排到列表最前(原来只有CSS着色,没有任何排序逻辑,已用真实群聊
置顶验证过排序生效且系统消息/文件助手两个固定入口仍保持在最前);好友聊天设置页
置顶/免打扰开关在初始GET未完成前禁用(避免`version:null`打到后端400);
`content-library-view.jsx`的`DownloadPage`修复带外附件下载使用未做域名归一化的裸
相对路径(生产环境webapp和文件域名跨域时会必现下载失败,已拆出`resolveFileUrl`
辅助函数只做URL拼装不做预先fetch,跟`getFileUrl`的实际下载区分开);
`security-support-view.jsx`的意见反馈分类配置改用`useCachedData`获得重试能力(原来
独立实现的边车缓存逻辑没享受到本轮`use-cached-data.js`的重试修复)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:03:41 +08:00

2934 lines
119 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The top-level class to hold all functionality together.
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { initializeApp as firebaseInitApp } from 'firebase/app';
import { getMessaging as firebaseGetMessaging, getToken as firebaseGetToken,
deleteToken as firebaseDelToken, onMessage as firebaseOnMessage } from 'firebase/messaging';
import { Drafty, Tinode } from 'tinode-sdk';
import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx';
import BatchForwardDialog from '../widgets/batch-forward-dialog.jsx';
import CallIncoming from '../widgets/call-incoming.jsx';
import QrScanner, { GroupJoinPreview } from '../widgets/qr-scanner.jsx';
const PhoneCountrySelector = React.lazy(_ => import('../widgets/phone-country-selector.jsx'));
const InfoView = React.lazy(_ => import('./info-view.jsx'));
const MessagesView = React.lazy(_ => import('./messages-view.jsx'));
import SidepanelView from './sidepanel-view.jsx';
import { API_KEY, APP_NAME, BASE_APP_NAME, DEFAULT_COLOR_SCHEME, DEFAULT_P2P_ACCESS_MODE, DEFAULT_TEXT_SIZE, FORWARDED_PREVIEW_LENGTH,
LOGGING_ENABLED, MEDIA_BREAKPOINT, WAKE_UP_TICK, WAKE_UP_TIMEOUT } from '../config.js';
import { CALL_STATE_NONE, CALL_STATE_OUTGOING_INITATED,
CALL_STATE_INCOMING_RECEIVED, CALL_STATE_IN_PROGRESS,
CALL_HEAD_STARTED } from '../constants.js';
import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest, getBootstrap } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
// Sound to play on message received.
const POP_SOUND = new Audio('audio/msg.m4a');
const messages = defineMessages({
reconnect_countdown: {
id: 'reconnect_countdown',
defaultMessage: 'Disconnected. Reconnecting in {seconds}…',
description: 'Message shown when an app update is available.'
},
reconnect_now: {
id: 'reconnect_now',
defaultMessage: 'Try now',
description: 'Prompt for reconnecting now'
},
push_init_failed: {
id: 'push_init_failed',
defaultMessage: 'Failed to initialize push notifications',
description: 'Error message when push notifications have failed to initialize.'
},
invalid_security_token: {
id: 'invalid_security_token',
defaultMessage: 'Invalid security token',
description: 'Error message when resetting password.'
},
no_connection: {
id: 'no_connection',
defaultMessage: 'No connection',
description: 'Warning that the user is offline.'
},
code_doesnot_match: {
id: 'code_doesnot_match',
defaultMessage: 'Code does not match',
description: 'Error message when the credential validation code is incorrect.'
},
menu_item_info: {
id: 'menu_item_info',
defaultMessage: 'Info',
description: 'Show extended topic information'
},
menu_item_audio_call: {
id: 'menu_item_audio_call',
defaultMessage: 'Call',
description: 'Start audio call'
},
menu_item_video_call: {
id: 'menu_item_video_call',
defaultMessage: 'Video call',
description: 'Start video call'
},
cred_confirmed_successfully: {
id: 'cred_confirmed_successfully',
defaultMessage: 'Confirmed successfully',
description: 'Notification message that the credential was successfully validated.'
},
password_reset_success: {
id: 'password_reset_success',
defaultMessage: 'Password reset successfully',
description: 'Notification message that the password was successfully reset.'
},
select_country: {
id: 'select_country',
defaultMessage: 'Select country',
description: 'Placeholder for the country selector'
}
});
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的
// "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的
// ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的
// 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。
const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','crop','edit','general',
'newtpk','notif','register','reset','security','settings','support','wallpapers',
'tenant','login','enterprise-register','enterprise-feedback','identity-check',
'realname','realname-verified','realname-reminder','independent-password',
'messages-home','messages-offline','messages-menu','message-actions','system-messages',
'chat-inbound-request','chat-inbound-message','chat-outbound-request','chat-status',
'chat-friend','chat-media','chat-blocked','chat-deleted','chat-standard',
'chat-attachments','chat-mixed','chat-contact-card',
'compose-keyboard','mentions-select','mentions-complete','mentions-message',
'emoji-panel','emoji-message','voice-ready','voice-hold','voice-recording',
'media-uploading','media-gallery','media-retry','upload-file','file-message',
'message-multiselect','favorite-success','forward-select','forward-multiple',
'forward-individual','forward-bundle',
'address-book','friend-requests','add-friend','my-friend-qr','friend-qr','friend-search-result',
'friend-verification','friend-profile','group-member-profile','friend-settings',
'friend-settings-blocked','friend-delete-confirm','block-list','block-confirm','clear-chat-confirm',
'group-list','group-empty','group-invite','group-events','group-join-requests',
'group-new-request','group-announcement','group-summary','group-announcement-detail',
'group-settings','group-profile','group-avatar','group-nickname','group-description',
'group-my-nickname','group-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-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',
'cache-clear-confirm','clear-history-confirm','change-password','delete-account-confirm',
'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': '没有权限执行此操作,请重新登录'
};
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) || '登录失败,请重试';
}
// See armCallRingTimeout below.
const CALL_RING_TIMEOUT_MS = 60000;
class TinodeWeb extends React.Component {
constructor(props) {
super(props);
this.selfRef = React.createRef();
this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this);
this.handleHashRoute = this.handleHashRoute.bind(this);
this.handleOnline = this.handleOnline.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
this.checkForAppUpdate = this.checkForAppUpdate.bind(this);
this.handleVisibilityEvent = this.handleVisibilityEvent.bind(this);
this.handleError = this.handleError.bind(this);
this.handleLoginRequest = this.handleLoginRequest.bind(this);
this.handlePersistenceChange = this.handlePersistenceChange.bind(this);
this.handleConnected = this.handleConnected.bind(this);
this.handleAutoreconnectIteration = this.handleAutoreconnectIteration.bind(this);
this.doLogin = this.doLogin.bind(this);
this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
this.handleTenantResolved = this.handleTenantResolved.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this);
this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
this.tnMeSubsUpdated = this.tnMeSubsUpdated.bind(this);
this.resetContactList = this.resetContactList.bind(this);
this.tnInitFind = this.tnInitFind.bind(this);
this.tnFndSubsUpdated = this.tnFndSubsUpdated.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleTopicSelected = this.handleTopicSelected.bind(this);
this.handleHideMessagesView = this.handleHideMessagesView.bind(this);
this.handleSendMessage = this.handleSendMessage.bind(this);
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
this.handleNewAccount = this.handleNewAccount.bind(this);
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
this.handleImRegisterRequest = this.handleImRegisterRequest.bind(this);
this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this);
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
this.handleChangeColorSchema = this.handleChangeColorSchema.bind(this);
this.handleChangeTextSize = this.handleChangeTextSize.bind(this);
this.handleSendOnEnter = this.handleSendOnEnter.bind(this);
this.handleSelectWallpapers = this.handleSelectWallpapers.bind(this);
this.handleWallpaperSelected = this.handleWallpaperSelected.bind(this);
this.handleSettings = this.handleSettings.bind(this);
this.handleGlobalSettings = this.handleGlobalSettings.bind(this);
this.handleShowArchive = this.handleShowArchive.bind(this);
this.handleShowBlocked = this.handleShowBlocked.bind(this);
this.handleToggleMessageSounds = this.handleToggleMessageSounds.bind(this);
this.handleCredAdd = this.handleCredAdd.bind(this);
this.handleCredDelete = this.handleCredDelete.bind(this);
this.handleCredConfirm = this.handleCredConfirm.bind(this);
this.initFCMessaging = this.initFCMessaging.bind(this);
this.toggleFCMToken = this.toggleFCMToken.bind(this);
this.handlePushMessage = this.handlePushMessage.bind(this);
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
this.handleStartTopicRequest = this.handleStartTopicRequest.bind(this);
this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
this.handleUnarchive = this.handleUnarchive.bind(this);
this.handleChangePermissions = this.handleChangePermissions.bind(this);
this.handleTagsUpdateRequest = this.handleTagsUpdateRequest.bind(this);
this.handleLogout = this.handleLogout.bind(this);
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
this.handleDeleteTopicRequest = this.handleDeleteTopicRequest.bind(this);
this.handleDeleteMessagesRequest = this.handleDeleteMessagesRequest.bind(this);
this.handleLeaveUnsubRequest = this.handleLeaveUnsubRequest.bind(this);
this.handleBlockTopicRequest = this.handleBlockTopicRequest.bind(this);
this.handleReportTopic = this.handleReportTopic.bind(this);
this.handleShareTheCard = this.handleShareTheCard.bind(this);
this.handleShowContextMenu = this.handleShowContextMenu.bind(this);
this.defaultTopicContextMenu = this.defaultTopicContextMenu.bind(this);
this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
this.handleShowAlert = this.handleShowAlert.bind(this);
this.handleShowInfoView = this.handleShowInfoView.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
this.handleResetPassword = this.handleResetPassword.bind(this);
this.handleContextMenuAction = this.handleContextMenuAction.bind(this);
this.handleShowCountrySelector = this.handleShowCountrySelector.bind(this);
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
this.handleEnterSelectMode = this.handleEnterSelectMode.bind(this);
this.handleToggleMessageSelect = this.handleToggleMessageSelect.bind(this);
this.handleExitSelectMode = this.handleExitSelectMode.bind(this);
this.handleShowBatchForward = this.handleShowBatchForward.bind(this);
this.handleHideBatchForward = this.handleHideBatchForward.bind(this);
this.handleOpenScanner = this.handleOpenScanner.bind(this);
this.handleCloseScanner = this.handleCloseScanner.bind(this);
this.handleQrDetected = this.handleQrDetected.bind(this);
this.handleSubmitGroupJoinRequest = this.handleSubmitGroupJoinRequest.bind(this);
this.handleCancelGroupJoinPreview = this.handleCancelGroupJoinPreview.bind(this);
this.handleStartVideoCall = this.handleStartVideoCall.bind(this);
this.handleStartAudioCall = this.handleStartAudioCall.bind(this);
this.handleInfoMessage = this.handleInfoMessage.bind(this);
this.handleDataMessage = this.handleDataMessage.bind(this);
this.handleCallClose = this.handleCallClose.bind(this);
this.handleCallInvite = this.handleCallInvite.bind(this);
this.handleCallRinging = this.handleCallRinging.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this);
this.handleCallSendOffer = this.handleCallSendOffer.bind(this);
this.handleCallIceCandidate = this.handleCallIceCandidate.bind(this);
this.handleCallSendAnswer = this.handleCallSendAnswer.bind(this);
this.handleCallAccept = this.handleCallAccept.bind(this);
this.sendMessageToTopic = this.sendMessageToTopic.bind(this);
// FIXME: this is unused.
this.callTimeoutTimer = null;
}
getBlankState() {
const settings = LocalStorageUtil.getObject('settings') || {};
const persist = !!LocalStorageUtil.getObject('keep-logged-in');
if (!settings.wallpaper) {
const wallpaper = defaultWallpaper();
settings.wallpaper = wallpaper.name || '';
settings.wallpaperSize = wallpaper.size || 0;
settings.wallpaperBlur = 0;
}
return {
connected: false,
// Connected and subscribed to 'me'
ready: false,
// Try to re-login on new connection.
autoLogin: false,
transport: settings.transport || null,
serverAddress: settings.serverAddress || detectServerAddress(),
secureConnection: settings.secureConnection === undefined ?
isSecureConnection() : settings.secureConnection,
serverVersion: "no connection",
// "On" is the default, so saving the "off" state.
messageSounds: !settings.messageSoundsOff,
incognitoMode: false,
// Manually selected color scheme. Default: 'system default' ('auto').
colorSchema: settings.colorSchema || DEFAULT_COLOR_SCHEME,
systemColorSchema: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
// Wallpaper settings.
wallpaper: settings.wallpaper,
wallpaperSize: settings.wallpaperSize,
wallpaperBlur: 0,
// Text size in points. Default size: 10pt
textSize: settings.textSize || DEFAULT_TEXT_SIZE,
// Default: 'plain' (just Enter)
sendOnEnter: settings.sendOnEnter || 'plain',
// Persistent request to enable alerts.
desktopAlerts: persist && !!settings.desktopAlerts,
// Enable / disable checkbox.
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
(typeof firebaseInitApp != 'undefined') && (typeof navigator != 'undefined') &&
(typeof FIREBASE_INIT != 'undefined'),
firebaseToken: persist ? LocalStorageUtil.getObject('firebase-token') : null,
applicationVisible: !document.hidden,
errorText: '',
errorLevel: null,
errorAction: undefined,
errorActionText: null,
sidePanelSelected: 'tenant',
sidePanelParams: {},
sidePanelTitle: null,
sidePanelAvatar: null,
myTrustedBadges: [],
loadSpinnerVisible: false,
login: '',
password: '',
persist: persist,
myUserId: null,
liveConnection: navigator.onLine,
topicSelected: '',
topicSelectedOnline: false,
topicSelectedAcs: null,
newTopicParams: null,
loginDisabled: false,
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
infoPanel: undefined,
mobilePanel: 'sidepanel',
// Video calls.
callTopic: undefined,
callState: CALL_STATE_NONE,
callAudioOnly: undefined,
// If true, call state should be transitioned to CALL_STATE_IN_PROGRESS upon
// switching to the call topic.
callShouldStart: false,
contextMenuVisible: false,
contextMenuBounds: null,
contextMenuClickAt: null,
contextMenuParams: null,
contextMenuItems: [],
forwardDialogVisible: false,
forwardMessage: null,
// Ch.4 multi-select: entered via the message context menu's "menu_item_select" item.
selectMode: false,
selectModeTopic: null,
selectedSeqs: [],
batchForwardVisible: false,
// QR scanning ("扫一扫", Ch.2/5). Holds live camera state so it's modeled the same way as
// the call panel/forward dialog above, not as a hash route -- there's nothing to deep-link
// into mid-scan, and the camera stream needs a reliable single owner to tear down.
scannerOpen: false,
// Set once a "join_group" QR resolves to a real group the user isn't in yet (status
// "can_request") -- holds enough of the group's summary to render the confirm sheet, plus
// the scanned token so the join request can reference how it was created.
groupJoinPreview: null,
// Modal alert dialog.
alertVisible: false,
alertParams: {},
// Chats as shown in the ContactsView
chatList: [],
// Contacts returned by a search query.
searchResults: [],
// Merged results of a search query and p2p chats.
searchableContacts: [],
// Credential validation.
reqCredMethod: undefined,
credMethod: undefined,
credCode: undefined,
credToken: undefined,
// Topic to go to after login.
requestedTopic: undefined
};
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
this.handleOnlineOn = _ => { this.handleOnline(true); }
window.addEventListener('online', this.handleOnlineOn);
this.handleOnlineOff = _ => { this.handleOnline(false); }
window.addEventListener('offline', this.handleOnlineOff);
window.addEventListener('hashchange', this.handleHashRoute);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange);
// Window/tab visible or invisible for pausing timers.
document.addEventListener('visibilitychange', this.handleVisibilityEvent);
// Listen for Escape key to clear selections.
document.addEventListener('keydown', this.handleEscapeKey);
document.documentElement.style.colorScheme = this.state.colorSchema == 'auto' ? 'light dark' : this.state.colorSchema;
document.documentElement.style.setProperty('--message-text-size', `${this.state.textSize}pt`);
this.applyWallpaperSettings(this.state.wallpaper, this.state.wallpaperSize, this.state.wallpaperBlur,
this.state.colorSchema == 'auto' ? this.state.systemColorSchema : this.state.colorSchema);
// Process background notifications from the service worker.
if (typeof BroadcastChannel == 'function') {
const serviceWorkerChannel = new BroadcastChannel('tinode-sw');
serviceWorkerChannel.addEventListener('message', this.handlePushMessage);
} else {
// Safari is broken by design.
console.warn("Your browser does not support BroadcastChannel. Some features will not be available");
}
// Detect if computer just woke up from sleep. Checks how much time has passed since the last tick.
// If too much time has passed then the computer was asleep.
this.lastWakeUpCheck = new Date().getTime();
this.wakeUpTicker = setInterval(_ => {
const now = new Date().getTime();
if (now - this.lastWakeUpCheck > WAKE_UP_TIMEOUT) {
// Trigger reconnect.
this.handleOnlineOn();
}
this.lastWakeUpCheck = now;
}, WAKE_UP_TICK);
this.setState({
viewportWidth: document.documentElement.clientWidth,
viewportHeight: document.documentElement.clientHeight
});
new Promise((resolve, reject) => {
// Must match state.secureConnection's own fallback (line ~233), not just isSecureConnection()
// directly -- otherwise a tenant resolved on a prior visit (which persists secureConnection via
// handleTenantResolved) diverges from the client actually constructed here on this load, and
// handleTenantResolved's own "did anything change" check (comparing against this same state)
// then wrongly concludes there's nothing to reconnect.
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection, this.state.transport,
this.props.intl.locale, this.state.persist, resolve);
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
this.tinode.onInfoMessage = this.handleInfoMessage;
this.tinode.onDataMessage = this.handleDataMessage;
}).then(_ => {
// Initialize desktop alerts.
if (this.state.desktopAlertsEnabled) {
this.initFCMessaging().catch(_ => {
// do nothing: handled earlier.
// catch needed to pervent unnecessary logging of error.
});
}
// Read contacts from cache.
this.resetContactList();
const token = this.state.persist ? LocalStorageUtil.getObject('auth-token') : undefined;
if (token) {
this.setState({autoLogin: true});
// When reading from storage, date is returned as string.
token.expires = new Date(token.expires);
this.tinode.setAuthToken(token);
this.tinode.connect().catch(err => {
// Socket error
this.handleError(err.message, 'err');
});
}
// Parse the hash navigation params.
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
// Maybe navigate to home screen.
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
// Save possible topic name.
this.setState({requestedTopic: parsedNav.path[1]});
const path = parsedNav.params && parsedNav.params.cred_done ?
HashNavigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
'';
HashNavigation.navigateTo(path);
} else {
this.handleHashRoute();
}
});
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('hashchange', this.handleHashRoute);
window.removeEventListener('online', this.handleOnlineOn);
window.removeEventListener('offline', this.handleOnlineOff);
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange);
document.removeEventListener('visibilitychange', this.handleVisibilityEvent);
document.removeEventListener('keydown', this.handleEscapeKey);
clearInterval(this.wakeUpTicker);
}
// Setup transport (usually websocket) and server address. This will terminate connection with the server.
static tnSetup(serverAddress, secureConnection, transport, locale, persistentCache, onSetupCompleted) {
const tinode = new Tinode({appName: APP_NAME, host: serverAddress, apiKey: API_KEY, transport: transport,
secure: secureConnection, persist: persistentCache}, onSetupCompleted);
tinode.setHumanLanguage(locale);
tinode.enableLogging(LOGGING_ENABLED, true);
TinodeWeb.installTenantHiPatch(tinode);
return tinode;
}
// The server extends two stock packets with fields the JS SDK has no API for (server/datamodel.go):
// MsgClientHi.Tenant (`hi.tenant`, resolved via POST /v1/im/tenants/resolve) and MsgClientAcc's
// `username`/`phone`/`invite_code` (used by server/user.go replyCreateUser during registration).
// #initPacket is a private class field so the only usable hook is wrapping the connection's sendText
// and injecting fields into the outgoing packet's already-serialized JSON. A cheap substring check
// keeps this a no-op for the hot path (every {pub}/{note}/... also goes through sendText).
static installTenantHiPatch(tinode) {
const conn = tinode && tinode._connection;
if (!conn || conn.__tenantPatched) {
return;
}
const original = conn.sendText.bind(conn);
conn.sendText = msg => {
if (typeof msg == 'string' && (msg.indexOf('"hi":') >= 0 || msg.indexOf('"acc":') >= 0)) {
try {
const parsed = JSON.parse(msg);
if (parsed.hi) {
const tenant = getTenantContext();
if (tenant && tenant.code) {
parsed.hi.tenant = tenant.code;
msg = JSON.stringify(parsed);
}
} else if (parsed.acc && tinode.__pendingAccExtra) {
Object.assign(parsed.acc, tinode.__pendingAccExtra);
tinode.__pendingAccExtra = null;
msg = JSON.stringify(parsed);
}
} catch (e) {
// Not valid JSON -- send as is.
}
}
return original(msg);
};
conn.__tenantPatched = true;
}
// Tinode received a push notification from the server.
handlePushMessage(payload) {
this.tinode.oobNotification(payload.data || {});
}
initFCMessaging() {
const {formatMessage, locale} = this.props.intl;
const onError = (msg, err) => {
console.error(msg, err);
this.handleError(formatMessage(messages.push_init_failed), 'err');
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);
// 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');
}
TinodeWeb.navigateToCredentialsView(ctrl.params);
} 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 才能显示出来。
}
});
}
static navigateToCredentialsView(params) {
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));
}
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,
credMethod: undefined,
credCode: undefined,
credToken: undefined,
myUserId: this.tinode.getCurrentUserID(),
autoLogin: true,
requestedTopic: undefined,
});
// Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched.
me.subscribe(
me.startMetaQuery().
withLaterSub().
withDesc().
withTags().
withCred().
build()
).catch(err => {
this.tinode.disconnect();
localStorage.removeItem('auth-token');
this.handleError(err.message, 'err');
HashNavigation.navigateTo('');
}).finally(_ => {
this.setState({loadSpinnerVisible: false});
});
// The new bottom nav only has "workbench" and "messages" -- there's no standalone contacts
// entry point anymore, so landing a fresh login there orphaned the user outside the nav they can
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
//
// 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件
// 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) ||
!SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ?
HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
}
HashNavigation.navigateTo(urlHash);
}
tnMeMetaDesc(desc) {
if (!desc) {
return;
}
if (desc.public) {
this.setState({
sidePanelTitle: desc.public.fn,
sidePanelAvatar: makeImageUrl(desc.public.photo)
});
}
if (desc.trusted) {
const badges = [];
for (const [key, val] of Object.entries(desc.trusted)) {
if (val) {
badges.push(key);
}
}
this.setState({
myTrustedBadges: badges,
});
}
if (desc.acs) {
this.setState({
incognitoMode: !desc.acs.isPresencer()
});
}
if (TinodeWeb.checkIfPinsUpdated(desc, this.state.chatList)) {
this.resetContactList();
}
}
// Reactions to updates to the contact list.
tnMeContactUpdate(what, cont) {
if (what == 'on' || what == 'off') {
this.resetContactList();
if (this.state.topicSelected == cont.topic) {
this.setState({topicSelectedOnline: (what == 'on')});
}
} else if (what == 'read') {
this.resetContactList();
} else if (what == 'msg' && cont) {
// Check if the topic is archived. If so, don't play a sound.
const topic = this.tinode.getTopic(cont.topic);
const archived = topic && topic.isArchived();
// New message received. Maybe the message is from the current user, then unread is 0.
if (cont.unread > 0 && this.state.messageSounds && !archived) {
// Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
if (document.hidden || this.state.topicSelected != cont.topic) {
POP_SOUND.play().catch(_ => {
// play() throws if the user did not click the app first: https://goo.gl/xX8pDD.
});
}
}
// Reorder contact list to use possibly updated 'touched'.
this.resetContactList();
} else if (what == 'recv') {
// Explicitly ignoring "recv" -- it causes no visible updates to contact list.
} else if (what == 'gone' || what == 'unsub') {
// Topic deleted or user unsubscribed. Remove topic from view.
// If the currently selected topic is gone, clear the selection.
if (this.state.topicSelected == cont.topic) {
this.handleTopicSelected(null);
}
// Redraw without the deleted topic.
this.resetContactList();
} else if (what == 'acs') {
// Permissions changed. If it's for the currently selected topic,
// update the views.
if (this.state.topicSelected == cont.topic) {
this.setState({topicSelectedAcs: cont.acs});
}
} else if (what == 'del') {
// TODO: messages deleted (hard or soft) -- update pill counter.
} else if (what == 'upd' || what == 'call') {
// upd, call - handled by the SDK. Explicitly ignoring here.
} else {
// TODO(gene): handle other types of notifications:
// * ua -- user agent changes (maybe display a pictogram for mobile/desktop).
console.info("Unsupported (yet) presence update:", what, "in", (cont || {}).topic);
}
}
tnMeSubsUpdated(unused) {
this.resetContactList();
}
// Merge search results and contact list to create a single flat
// list of known contacts for GroupManager to use.
static prepareSearchableContacts(chatList, foundContacts) {
const merged = {};
// For chatList topics merge only p2p topics and convert them to the
// same format as foundContacts.
for (const c of chatList) {
if (Tinode.isP2PTopicName(c.topic)) {
merged[c.topic] = {
user: c.topic,
updated: c.updated,
public: c.public,
private: c.private,
acs: c.acs
};
}
}
// Add all foundCountacts if they have not been added already.
for (const c of foundContacts) {
if (!merged[c.user]) {
merged[c.user] = c;
}
}
return Object.values(merged);
}
resetContactList() {
const newState = {
chatList: []
};
if (!this.state.ready) {
newState.ready = true;
}
const me = this.tinode.getMeTopic();
me.contacts(c => {
if (!c.topic && !c.user) {
// Contacts expect c.topic to be set.
c.topic = c.name;
}
newState.chatList.push(c);
if (this.state.topicSelected == c.topic) {
newState.topicSelectedOnline = c.online;
newState.topicSelectedAcs = c.acs;
}
});
const past = new Date(0);
newState.chatList.sort((a, b) => {
// Pinned topics take precedence.
const pins = me.pinnedTopicRank(b.topic) - me.pinnedTopicRank(a.topic);
// Then sort by last touched time.
return pins != 0 ? pins : (b.touched || past).getTime() - (a.touched || past).getTime();
});
// Merge search results and chat list.
newState.searchableContacts = TinodeWeb.prepareSearchableContacts(newState.chatList, this.state.searchResults);
this.setState(newState);
}
static checkIfPinsUpdated(me, chatList) {
for (let i = 0; i < chatList.length; i++) {
const c = chatList[i];
if (!!me.pinnedTopicRank(c.topic) != c.pinned) {
return true;
}
}
return false;
}
/* Fnd topic: find contacts by tokens */
tnInitFind() {
const fnd = this.tinode.getFndTopic();
fnd.onSubsUpdated = this.tnFndSubsUpdated;
if (fnd.isSubscribed()) {
this.tnFndSubsUpdated();
} else {
fnd.subscribe(fnd.startMetaQuery().withSub().build()).catch(err => {
this.handleError(err.message, 'err');
});
}
}
tnFndSubsUpdated() {
const foundContacts = [];
// Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
this.tinode.getFndTopic().contacts((s) => {
foundContacts.push(s);
});
this.setState({
searchResults: foundContacts,
searchableContacts: TinodeWeb.prepareSearchableContacts(this.state.chatList, foundContacts)
});
}
/** Called when the user enters a contact into the contact search field in the NewAccount panel
@param query {Array} is an array of contacts to search for
*/
handleSearchContacts(query) {
const fnd = this.tinode.getFndTopic();
fnd.setMeta({desc: {public: query}})
.then(_ => fnd.getMeta(fnd.startMetaQuery().withSub().build()))
.catch(err => this.handleError(err.message, 'err'));
}
// User clicked on a topic in the side panel or deleted a topic.
handleTopicSelected(topicName) {
// Clear newTopicParams after use.
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
this.setState({
newTopicParams: null
});
}
if (topicName) {
this.setState({
errorText: '',
errorLevel: null,
mobilePanel: 'topic-view',
infoPanel: undefined
});
// Different topic selected.
if (this.state.topicSelected != topicName) {
this.setState({
topicSelectedOnline: this.tinode.isTopicOnline(topicName),
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName),
forwardMessage: null
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
}
} else {
// Currently selected contact deleted
this.setState({
topicSelected: null,
errorText: '',
errorLevel: null,
mobilePanel: 'sidepanel',
topicSelectedOnline: false,
topicSelectedAcs: null,
infoPanel: undefined,
forwardMessage: null
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
}
}
// In mobile view user requested to show sidepanel
handleHideMessagesView() {
this.setState({
mobilePanel: 'sidepanel'
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
}
// User is sending a message, either plain text or a drafty object, possibly
// with attachments.
// - msg - Drafty message with content
// - promise - Promise to be resolved when the upload is completed
// - uploadCompletionPromise - for tracking progress
// - head - head dictionary to be attached to the message
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
const topic = this.tinode.getTopic(this.state.topicSelected);
return this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
}
sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head) {
msg = topic.createMessage(msg, false);
// The uploader is used to show progress.
msg._uploader = uploader;
if (head) {
msg.head = Object.assign(msg.head || {}, head);
}
const completion = [];
if (uploadCompletionPromise) {
completion.push(uploadCompletionPromise);
}
if (!topic.isSubscribed()) {
// Topic is not subscribed yet. Subscribe.
const subscribePromise =
topic.subscribe()
.then(_ => {
// If there are unsent messages (except video call messages),
// try sending them now. Unsent video call messages will be dropped.
let calls = [];
topic.queuedMessages(pub => {
if (pub._sending || pub.seq == msg.seq) {
return;
}
if (pub.head && pub.head.webrtc) {
// Filter out unsent video call messages.
calls.push(pub.seq);
return;
}
if (topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
if (calls.length > 0) {
topic.delMessagesList(calls, true);
}
});
completion.push(subscribePromise);
}
// TODO: check if return is required.
return topic.publishDraft(msg, Promise.all(completion))
.then(ctrl => {
if (topic.isArchived()) {
topic.archive(false);
}
return ctrl;
})
.catch(err => this.handleError(err.message, 'err'));
}
handleNewChatInvitation(topicName, action) {
const topic = this.tinode.getTopic(topicName);
let response = null;
switch (action) {
case 'accept':
// Accept given permissions.
const mode = topic.getAccessMode().getGiven();
response = topic.setMeta({sub: {mode: mode}});
if (topic.isP2PType()) {
// For P2P topics change 'given' permission of the peer too.
// In p2p topics the other user has the same name as the topic.
response = response.then(_ => topic.setMeta({sub: {user: topicName, mode: mode}}));
}
break;
case 'delete':
// Ignore invitation by deleting it.
response = topic.delTopic(true);
break;
case 'block':
// Ban the topic making futher invites impossible.
// Just self-ban.
const am = topic.getAccessMode().updateWant('-JP').getWant();
response = topic.setMeta({sub: {mode: am}}).then(_ => this.handleTopicSelected(null));
break;
default:
console.warn("Unknown invitation action", '"' + action + '""');
}
if (response != null) {
response.catch(err => this.handleError(err.message, 'err'));
}
}
// User chose a Sign Up menu item.
handleNewAccount() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'register'));
}
// Actual registration of a new account.
handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
// Clear old error, if any.
this.handleError();
this.tinode.connect(this.state.serverAddress)
.then(_ => {
let attachments;
if (public_ && public_.photo && public_.photo.ref) {
attachments = [public_.photo.ref];
}
return this.tinode.createAccountBasic(login_, password_,
{public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments});
}).then(ctrl => {
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
TinodeWeb.navigateToCredentialsView(ctrl.params);
} else {
this.handleLoginSuccessful(this);
}
}).catch(err => {
this.handleError(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) {
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;
}
}
handleCallRinging(callTopic, callSeq) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('ringing', callSeq);
}
handleCallHangup(callTopic, callSeq) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('hang-up', callSeq);
}
handleCallSendOffer(callTopic, callSeq, sdp) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('offer', callSeq, sdp);
}
handleCallIceCandidate(callTopic, callSeq, candidate) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('ice-candidate', callSeq, candidate);
}
handleCallSendAnswer(callTopic, callSeq, sdp) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('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(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
});
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-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-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',
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
'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);