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:
co-authored by
Claude Sonnet 5
parent
ba8f48ee83
commit
251c9de87c
+344
-21
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user