Convert im_webapp from static mock prototypes to real backend integration

Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.

Backup checkpoint before reconciling with origin/dev's parallel rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 11:40:53 +08:00
co-authored by Claude Sonnet 5
parent ba8f48ee83
commit 251c9de87c
36 changed files with 52812 additions and 5199 deletions
+344 -21
View File
@@ -11,7 +11,9 @@ 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'));
@@ -27,9 +29,11 @@ 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 } from '../lib/im-api.js';
import { writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, updateFavicon } from '../lib/utils.js';
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
// Sound to play on message received.
const POP_SOUND = new Audio('audio/msg.m4a');
@@ -119,6 +123,7 @@ class TinodeWeb extends React.Component {
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);
@@ -133,6 +138,7 @@ class TinodeWeb extends React.Component {
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);
@@ -182,6 +188,18 @@ class TinodeWeb extends React.Component {
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);
@@ -253,6 +271,7 @@ class TinodeWeb extends React.Component {
errorActionText: null,
sidePanelSelected: 'tenant',
sidePanelParams: {},
sidePanelTitle: null,
sidePanelAvatar: null,
myTrustedBadges: [],
@@ -289,6 +308,21 @@ class TinodeWeb extends React.Component {
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: {},
@@ -357,7 +391,12 @@ class TinodeWeb extends React.Component {
});
new Promise((resolve, reject) => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(), this.state.transport,
// 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;
@@ -423,9 +462,46 @@ class TinodeWeb extends React.Component {
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 || {});
@@ -593,7 +669,7 @@ class TinodeWeb extends React.Component {
'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-role-message','group-qr','group-governance','group-admins','group-admin-add',
'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',
@@ -603,19 +679,22 @@ class TinodeWeb extends React.Component {
'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-list','monthly-report-detail',
'executive-mailbox','executive-mailbox-notice',
'personal-center','personal-profile','profile-nickname','profile-birth-date',
'profile-signature','personal-qrcode','android-settings','cache-clear-confirm',
'app-version','app-update-available','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-confirm','youth-mode-on','youth-mode-disable',
'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',
''].includes(hash.path[0])) {
newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else {
console.warn("Unknown sidepanel view", hash.path[0]);
}
@@ -638,7 +717,7 @@ class TinodeWeb extends React.Component {
}
} else {
// Empty hashpath
Object.assign(newState, {sidePanelSelected: '', topicSelected: null});
Object.assign(newState, {sidePanelSelected: '', sidePanelParams: {}, topicSelected: null});
}
// Save credential validation parameters, if available.
@@ -890,6 +969,11 @@ class TinodeWeb extends React.Component {
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.
@@ -924,7 +1008,10 @@ class TinodeWeb extends React.Component {
}).finally(_ => {
this.setState({loadSpinnerVisible: false});
});
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'contacts');
// 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).
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'workbench');
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
}
@@ -1310,6 +1397,23 @@ class TinodeWeb extends React.Component {
});
}
// 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});
@@ -1424,6 +1528,36 @@ class TinodeWeb extends React.Component {
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,
@@ -1642,6 +1776,7 @@ class TinodeWeb extends React.Component {
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 => {
@@ -1666,7 +1801,9 @@ class TinodeWeb extends React.Component {
this.setState(this.getBlankState());
cleared.then(_ => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(),
// 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;
@@ -1679,7 +1816,7 @@ class TinodeWeb extends React.Component {
}
handleDeleteAccount() {
this.tinode.delCurrentUser(true).then(_ => {
return this.tinode.delCurrentUser(true).then(_ => {
this.handleLogout();
});
}
@@ -1744,14 +1881,24 @@ class TinodeWeb extends React.Component {
return;
}
// Publish spam report.
this.tinode.report('report', topicName);
// 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.
// Remove J and P permissions (the "block" half of "block and report").
topic.updateMode(null, '-JP')
.then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
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'));
}
@@ -1876,9 +2023,113 @@ class TinodeWeb extends React.Component {
}
} 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,
@@ -1893,7 +2144,35 @@ class TinodeWeb extends React.Component {
});
}
// 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'});
}
@@ -2179,7 +2458,7 @@ class TinodeWeb extends React.Component {
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-role-message', 'group-qr', 'group-governance',
'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);
@@ -2192,14 +2471,15 @@ class TinodeWeb extends React.Component {
'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', 'daily-report-editor', 'daily-report-mine', 'daily-report-team',
'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team',
'daily-report-detail', 'monthly-report-editor', 'monthly-report-list',
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
'profile-birth-date', 'profile-signature', 'personal-qrcode', 'android-settings',
'cache-clear-confirm', 'app-version', 'app-update-available', 'privacy-settings',
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on',
'youth-mode-disable', 'notification-permission', 'permission-settings-reminder',
'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);
@@ -2240,6 +2520,24 @@ class TinodeWeb extends React.Component {
:
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}
@@ -2255,6 +2553,20 @@ class TinodeWeb extends React.Component {
:
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}
@@ -2272,6 +2584,7 @@ class TinodeWeb extends React.Component {
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}
@@ -2314,8 +2627,10 @@ class TinodeWeb extends React.Component {
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}
@@ -2331,6 +2646,7 @@ class TinodeWeb extends React.Component {
onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected}
onOpenScanner={this.handleOpenScanner}
onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount}
@@ -2364,6 +2680,7 @@ class TinodeWeb extends React.Component {
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}
@@ -2377,6 +2694,12 @@ class TinodeWeb extends React.Component {
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}