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
+372 -28
View File
@@ -18,6 +18,7 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import MetaMessage from '../widgets/meta-message.jsx';
import PinnedMessages from '../widgets/pinned-messages.jsx';
import { RiskConfirmDialog } from './security-support-view.jsx';
import SendMessage from '../widgets/send-message.jsx';
const TheCardPreview = React.lazy(_ => import('../widgets/the-card-preview.jsx'));
const VideoPreview = React.lazy(_ => import('../widgets/video-preview.jsx'));
@@ -27,6 +28,9 @@ import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDI
MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY, QUOTED_REPLY_LENGTH, VIDEO_PREVIEW_DIM } from '../config.js';
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -141,10 +145,17 @@ class MessagesView extends React.Component {
super(props);
this.state = MessagesView.getDerivedStateFromProps(props, {});
// Not topic-specific, fetched once in componentDidMount -- unlike the rest of state this
// is intentionally not touched by getDerivedStateFromProps on topic change.
this.state.messagePolicy = null;
this.state.toast = null;
this.state.riskDialog = null;
this.componentSetup = this.componentSetup.bind(this);
this.leave = this.leave.bind(this);
this.sendMessage = this.sendMessage.bind(this);
this.handleRiskConfirm = this.handleRiskConfirm.bind(this);
this.handleRiskCancel = this.handleRiskCancel.bind(this);
this.retrySend = this.retrySend.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendVideoAttachment = this.sendVideoAttachment.bind(this);
@@ -170,6 +181,13 @@ class MessagesView extends React.Component {
this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.loadRelationshipContext = this.loadRelationshipContext.bind(this);
this.loadGroupContext = this.loadGroupContext.bind(this);
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
this.handleBatchDelete = this.handleBatchDelete.bind(this);
this.handleBatchFavorite = this.handleBatchFavorite.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachImageOrVideo = this.handleAttachImageOrVideo.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
@@ -182,6 +200,7 @@ class MessagesView extends React.Component {
this.handleEditMessage = this.handleEditMessage.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.jumpToMessage = this.jumpToMessage.bind(this);
this.handleUnpinMessage = this.handleUnpinMessage.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this);
@@ -249,12 +268,17 @@ class MessagesView extends React.Component {
this.mountDnDEvents(this.dndRef);
this.componentSetup({}, {});
getBootstrap().then(data => {
this.setState({messagePolicy: data.message_policy || null});
}).catch(_ => {});
}
componentWillUnmount() {
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
window.clearTimeout(this.toastTimer);
// Flush all notifications.
this.clearNotificationQueue();
@@ -290,6 +314,13 @@ class MessagesView extends React.Component {
this.postReadNotification(0);
}
// Ch.7: jump to a message found via chat-history search (content-library-view.jsx's History),
// passed in as a ?jumpseq= hash param rather than a normal prop since the navigation that gets us
// here also has to change the *topic*, which content-library-view.jsx has no direct way to do.
if (this.props.jumpToSeq && this.props.jumpToSeq != prevProps.jumpToSeq) {
this.jumpToMessage(parseInt(this.props.jumpToSeq, 10));
}
this.componentSetup(prevProps, prevState);
}
@@ -312,6 +343,9 @@ class MessagesView extends React.Component {
topic.onPres = this.handleSubsUpdated;
topic.onAuxUpdated = this.handleAuxUpdate;
}
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
}
if (topic) {
@@ -368,7 +402,12 @@ class MessagesView extends React.Component {
pins: [],
pinsLoaded: false,
selectedPin: 0,
subsVersion: 0
subsVersion: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -389,7 +428,18 @@ class MessagesView extends React.Component {
showGoToLastButton: false,
contentToEdit: null,
dragging: false,
selectedPin: 0
selectedPin: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0,
// MessagesView isn't remounted on topic switch (no `key={topic}` at the call site in
// tinode-web.jsx), so a pending risk-check dialog from the conversation just left would
// otherwise survive the switch -- confirming it would then send the old topic's message
// into whichever topic is now open, since doSend()'s `this.props.sendMessage` resolves the
// topic to send to at call time, not at the time the dialog was raised.
riskDialog: null
};
if (nextProps.forwardMessage) {
@@ -435,7 +485,7 @@ class MessagesView extends React.Component {
});
} else if (topic.public) {
Object.assign(nextState, {
title: topic.public.fn,
title: topicDisplayName(topic.name, topic.public, ''),
avatar: makeImageUrl(topic.public.photo)
});
} else {
@@ -517,7 +567,7 @@ class MessagesView extends React.Component {
nextState.isAdmin = false;
}
if (!prevState.readingBlocked) {
prevState.readingBlocked = true;
nextState.readingBlocked = true;
}
if (prevState.isSharer) {
nextState.isSharer = false;
@@ -692,7 +742,7 @@ class MessagesView extends React.Component {
});
} else if (desc.public) {
this.setState({
title: desc.public.fn,
title: topicDisplayName(this.props.topic, desc.public, ''),
avatar: makeImageUrl(desc.public.photo)
});
} else {
@@ -968,9 +1018,28 @@ class MessagesView extends React.Component {
params.userName = this.state.title;
}
params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || [];
let menuItems = messageSpecificMenuItems || [];
const topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
// Edit (Ch.4): chat-message.jsx already gated 'menu_item_edit' on writer + non-attachment
// content, but has no access to the server-configured edit window (that's session-level state
// living here, not a per-message prop) -- unlike recall just below, this was never actually
// filtered by age, so an edit attempt on a message well past the window would only fail at
// send time instead of never showing the option. Same window-check shape as recall's, just
// removing the item after the fact instead of adding it, since base eligibility already ran.
const editWindow = this.state.messagePolicy ? this.state.messagePolicy.edit_window_seconds : 0;
if (menuItems.includes('menu_item_edit') && !(editWindow > 0 && params.timestamp &&
params.timestamp.getTime() > (new Date().getTime() - editWindow * 1000))) {
menuItems = menuItems.filter(item => item !== 'menu_item_edit');
}
menuItems.push('menu_item_copy', 'menu_item_favorite');
// Recall (Ch.4): own message, real seq, and still inside the server-configured window.
// Distinct from delete -- see context-menu.jsx recallOwnMessage for why both exist.
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
if (params.userFrom == this.props.myUserId && params.seq > 0 && recallWindow > 0 && params.timestamp &&
params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
menuItems.push('menu_item_recall');
}
// Self -> (1) always hard-delete with [Delete] title.
// P2P -> (1) hard-delete if allowed and not too old, (2) always soft-delete.
// Group -> (1) hard-delete if allowed and not too old or owner, (2) always soft-delete.
@@ -1002,6 +1071,7 @@ class MessagesView extends React.Component {
}
}
}
menuItems.push('menu_item_select');
this.props.showContextMenu(params, menuItems);
}
@@ -1014,19 +1084,156 @@ class MessagesView extends React.Component {
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
}
// Fetch the real friend/block/pending relationship state for a 1:1 topic so the UI can explain
// (instead of silently disabling) why sending is restricted. Best-effort: a failed fetch just
// leaves relationshipContext null and the existing isWriter-based disable stays in effect.
loadRelationshipContext(topic) {
if (!topic || !topic.isP2PType()) {
return;
}
const topicName = topic.name;
getConversationContext(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({relationshipContext: data});
}
}).catch(_ => {});
}
// Ch.6: role badges (docs/images/群标识.png) and the announcement/join-request banners at the top of a
// group chat (docs/images/群公告状态.png, 新邀请进群申请.png) all need data no P2P topic has -- fetch it
// once per group-topic-load, same best-effort pattern as loadRelationshipContext above. Every `topic`
// field these Ch.6 endpoints return is a real grp-prefixed native Tinode name (verified against the Go
// source), so there's no Ch.5-style translation needed anywhere here.
loadGroupContext(topic) {
if (!topic || !topic.isGroupType() || topic.isChannelType()) {
return;
}
const topicName = topic.name;
getGroupMembers(topicName).then(data => {
if (this.state.topic != topicName) {
return;
}
const roles = {};
(data.items || []).forEach(m => { roles[m.user_id] = m.role; });
this.setState({groupRoles: roles, myGroupRole: roles[this.props.myUserId] || null});
// Pending join requests can only be resolved by an admin/owner -- only they can act on the banner,
// and only they pass requireIMGroupMember's IsAdmin() check on the list endpoint itself.
if (roles[this.props.myUserId] == 'owner' || roles[this.props.myUserId] == 'admin') {
getGroupJoinRequests(topicName, 'pending').then(reqData => {
if (this.state.topic == topicName) {
this.setState({pendingJoinRequests: (reqData.items || []).length});
}
}).catch(_ => {});
}
}).catch(_ => {});
getLatestGroupAnnouncement(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({groupAnnouncement: (data && data.content) ? data : null});
}
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
// selector on every hashchange, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target hash from an empty path instead of the current one.
handleShowAnnouncement(e) {
e.preventDefault();
// `from=chat` lets Announcement (group-management-view.jsx) tell this entry point apart from
// the one via group-settings -- both land on the identical path[0]/topic hash otherwise, so
// without it the "back" button can't know whether to return to group-settings or to this chat.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
}
handleShowJoinRequests(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic));
}
handleSendFriendVerification(e) {
e.preventDefault();
// Same stray-path[1] hazard as handleShowAnnouncement above (build from an empty base, not the
// current hash, since a topic is open) -- this one additionally never set `uid`, which the
// friend-verification page needs (contact-management-view.jsx's Verification reads hashParams.uid)
// to know who the verification is for; for a P2P topic that's simply the topic name itself.
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
}
showToast(text) {
this.setState({toast: text});
window.clearTimeout(this.toastTimer);
// Matches div.toast.show's own fadein(0.5s) + hold(2.5s) + fadeout(0.5s) animation length,
// so the class is removed right as the CSS fadeout finishes instead of cutting it short.
this.toastTimer = window.setTimeout(() => this.setState({toast: null}), 3500);
}
handleBatchDelete() {
const topic = this.props.tinode.getTopic(this.state.topic);
if (!topic || !this.props.selectedSeqs.length) {
return;
}
topic.delMessagesList(this.props.selectedSeqs.slice(), true).then(_ => {
this.props.onExitSelectMode();
}).catch(err => {
this.props.onError(err.message, 'err');
});
}
// Per docs/images/多选-选中对话内容-收藏成功.png the selection stays visible behind the toast --
// unlike delete, favoriting doesn't remove the messages, so there's no reason to exit select mode.
handleBatchFavorite() {
if (!this.props.selectedSeqs.length) {
return;
}
const topicName = this.state.topic;
favoriteMessages(this.props.selectedSeqs.map(seq => ({topic: topicName, seq}))).then(result => {
const failed = (result && result.failed) || [];
if (failed.length && failed.length === this.props.selectedSeqs.length) {
this.props.onError('收藏失败', 'err');
} else if (failed.length) {
this.showToast(`已收藏,${failed.length} 条失败`);
} else {
this.showToast('收藏成功');
}
}).catch(err => {
this.props.onError(err.message, 'err');
});
}
sendKeyPress(audio) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic.isSubscribed()) {
if (audio) {
topic.noteRecording(true);
} else {
topic.noteKeyPress();
}
if (!topic.isSubscribed()) {
return;
}
if (audio) {
// Doc's default for the "user is recording voice" indicator is OFF unless product opts in
// (a non-persisted {note what:"kpa"} broadcast to every other group member) -- this was firing
// unconditionally on every recording, contradicting that documented default. No product signal
// to turn it on yet, so this is a deliberate no-op rather than a removed feature: re-enable by
// restoring `topic.noteRecording(true);` here if/when that's confirmed wanted.
return;
}
topic.noteKeyPress();
}
// sendMessage sends the message with an optional subscription to topic first.
sendMessage(msg, uploadCompletionPromise, uploader) {
// typeHint (Ch.7): the backend's imMessageType() classifier -- which drives every type-filtered list
// in this chapter (favorites tabs, chat-history search/media tabs) -- only recognizes a flat
// content.file_id/content.url key or an explicit head['x-im-type'] hint; it never walks a real
// Drafty entity array. Since every attachment this app actually sends uses the standard ent[]
// format (Drafty.attachFile/insertImage/insertVideo), none of them carry a flat file_id, so without
// this hint every image/video/file/voice message silently classifies as plain "text" server-side.
sendMessage(msg, uploadCompletionPromise, uploader, typeHint) {
// Captured before the reply/edit branches below possibly rewrite `msg` into a Drafty object
// (Drafty.append(...) for a reply-with-quote) -- the URL risk check further down keys off
// `typeof msg == 'string'`, so without this a plain-text reply containing a link would silently
// skip the check entirely (a fresh, non-reply message with the same text does get checked).
const originalText = typeof msg === 'string' ? msg : null;
let head;
if (!msg && this.props.forwardMessage) {
// We are forwarding a message.
@@ -1053,7 +1260,59 @@ class MessagesView extends React.Component {
}
this.handleCancelReply();
}
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint};
}
const doSend = () => this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Pre-send URL risk check (POST /security/urls/check) -- the only Ch.11 "anti-fraud" mechanism
// with a real backend behind it. Only plain-text composed messages are checked (not forwards,
// uploads, or already-Drafty content) since that's the one path a URL could plausibly be typed in.
// Returns a promise resolving to `false` when the message was NOT sent (blocked/cancelled) so
// send-message.jsx can leave the composer text intact instead of optimistically clearing it --
// without this, a blocked message's text would just vanish with no failed-message trace to retry.
const urlMatch = originalText && originalText.match(/https?:\/\/\S+/);
if (urlMatch) {
return checkURL(urlMatch[0], 'message_compose').then(result => {
if (result.decision === 'allow') {
return doSend();
}
return new Promise(resolve => {
// riskDialog is a single slot -- if an earlier risk check is still awaiting the user's
// decision (two messages sent in quick succession, both flagged), overwriting it here
// would leave that first promise permanently unresolved and its message silently dropped
// with send-message.jsx never told to stop holding it as unsent. Resolving it `false` first
// means it's treated the same as the user cancelling it -- lost, but not hung forever.
if (this.state.riskDialog) this.state.riskDialog.resolve(false);
this.setState({riskDialog: {blocked: result.decision === 'block', challengeId: result.risk_challenge_id, send: doSend, resolve}});
});
}).catch(_ => doSend()); // Don't let a risk-check failure block sending altogether.
}
return doSend();
}
handleRiskConfirm() {
const dialog = this.state.riskDialog;
if (!dialog) return;
this.setState({riskDialog: null});
const finish = () => dialog.resolve(dialog.send());
if (dialog.challengeId) {
confirmRiskChallenge(dialog.challengeId, 'continue').then(finish).catch(finish);
} else {
finish();
}
}
handleRiskCancel() {
const dialog = this.state.riskDialog;
if (!dialog) return;
this.setState({riskDialog: null});
if (dialog.challengeId && !dialog.blocked) {
confirmRiskChallenge(dialog.challengeId, 'cancel').catch(_ => {});
}
dialog.resolve(false);
}
// Retry sending a message.
@@ -1090,7 +1349,7 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
const uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1103,7 +1362,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'file');
} else {
// Small enough to send inband.
fileToBase64(file)
@@ -1112,7 +1371,7 @@ class MessagesView extends React.Component {
data: b64.bits,
filename: b64.name,
size: file.size
})))
}), undefined, undefined, 'file'))
.catch(err => this.props.onError(err.message, 'err'));
}
}
@@ -1185,7 +1444,7 @@ class MessagesView extends React.Component {
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
const uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1212,7 +1471,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'image');
})
.catch(err => this.props.onError(err, 'err'));
return;
@@ -1234,7 +1493,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption));
}
this.sendMessage(msg);
this.sendMessage(msg, undefined, undefined, 'image');
});
}
@@ -1251,7 +1510,7 @@ class MessagesView extends React.Component {
let uploader;
if ((videoBlob.size + previewBlob.size) > maxInbandAttachmentSize) {
// One or both are too large to send inband. Uploading out of band and sending as a link.
uploader = this.props.tinode.getLargeFileHelper();
uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1281,7 +1540,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption));
}
this.sendMessage(msg);
this.sendMessage(msg, undefined, undefined, 'video');
});
return;
}
@@ -1320,7 +1579,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'video');
})
.catch(err => this.props.onError(err.message, 'err'));
}
@@ -1379,7 +1638,7 @@ class MessagesView extends React.Component {
const maxInbandAttachmentSize = this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
const uploader = createChatUploader(this.props.tinode);
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1393,7 +1652,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'voice');
} else {
// Small enough to send inband.
blobToBase64(blob)
@@ -1404,7 +1663,7 @@ class MessagesView extends React.Component {
size: blob.size,
duration: duration,
preview: preview,
}))
}), undefined, undefined, 'voice')
})
}
})
@@ -1494,6 +1753,27 @@ class MessagesView extends React.Component {
}
}
// Ch.7 chat-history search result -> jump to the message in the real conversation. handleQuoteClick
// only scrolls to a message already present in this topic's locally-cached message list (used
// elsewhere for quoted-reply/pinned-message jumps, where that's always true); a search result can
// point at a message far outside whatever window happens to be loaded, so fetch a window around it
// first via the native SDK (not the REST search API -- handleQuoteClick reads refs off the SDK's own
// local topic cache, so the data has to land there, which only getMeta does).
jumpToMessage(seq) {
const topic = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
if (!topic || !seq || isNaN(seq)) {
return;
}
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'jumpseq'));
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
if (topic.findMessage(seq)) {
jump();
} else {
topic.getMeta(topic.startMetaQuery().withData(Math.max(1, seq - 20), seq + 21, 41).build())
.then(jump).catch(err => this.props.onError(err.message, 'err'));
}
}
handleUnpinMessage(seq) {
const topic = this.props.tinode.getTopic(this.state.topic);
topic.pinMessage(seq, false);
@@ -1568,6 +1848,10 @@ class MessagesView extends React.Component {
onSendAnswer={this.props.onCallSendAnswer} />
</Suspense>;
const riskDialog = this.state.riskDialog &&
<RiskConfirmDialog blocked={this.state.riskDialog.blocked}
onConfirm={this.handleRiskConfirm} onCancel={this.handleRiskCancel} />;
let component;
if (!this.state.topic) {
component = <LogoView
@@ -1733,6 +2017,7 @@ class MessagesView extends React.Component {
topic={this.state.topic}
content={msg.content}
mimeType={msg.head && msg.head.mime}
head={msg.head}
replyToSeq={replyToSeq}
edited={msg.head && !msg.head.webrtc && msg.head.replace}
timestamp={msg.ts}
@@ -1743,11 +2028,17 @@ class MessagesView extends React.Component {
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
senderRole={groupTopic && this.state.groupRoles ? this.state.groupRoles[thisFrom] : null}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
userIsWriter={this.state.isWriter}
userIsAdmin={this.state.isAdmin}
relationship={this.state.relationshipContext ? this.state.relationshipContext.relationship : null}
onSendFriendVerification={this.handleSendFriendVerification}
selectMode={this.props.selectMode}
selected={this.props.selectedSeqs && this.props.selectedSeqs.includes(msg.seq)}
onToggleSelect={this.props.onToggleMessageSelect}
pinned={this.state.pins.includes(msg.seq)}
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.handleShowMessageContextMenu}
@@ -1797,6 +2088,18 @@ class MessagesView extends React.Component {
let messagesComponent = (
<>
{this.state.groupAnnouncement ?
<a href="#" id="group-announcement-banner" onClick={this.handleShowAnnouncement}>
<i className="material-icons">campaign</i>
<span>{this.state.groupAnnouncement.content}</span>
<i className="material-icons">chevron_right</i>
</a> : null}
{this.state.pendingJoinRequests > 0 ?
<a href="#" id="group-join-request-banner" onClick={this.handleShowJoinRequests}>
<i className="material-icons">person_add_alt</i>
<span>新邀请进群申请({this.state.pendingJoinRequests})</span>
<i className="material-icons">chevron_right</i>
</a> : null}
<div id="messages-container" className={darkModeClass}>
<button id="go-to-latest" className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
onClick={this.goToLatestMessage}>
@@ -1817,6 +2120,9 @@ class MessagesView extends React.Component {
: null }
</div>
: null }
<div className={'toast' + (this.state.toast ? ' show' : '')}>
<i className="material-icons">check</i>&nbsp;{this.state.toast}
</div>
</div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note">
@@ -1826,13 +2132,51 @@ class MessagesView extends React.Component {
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
</div> : null}
{this.state.unconfirmed ?
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'pending_inbound' ?
<div id="relationship-status-note">以上是打招呼的消息</div> : null}
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'pending_outbound' ?
<div id="relationship-status-note">好友申请正在等待处理</div> : null}
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'none' ?
<div id="relationship-status-note">你们还不是好友,发送消息前需先验证 <a href="#"
onClick={this.handleSendFriendVerification}>发送朋友验证</a></div> : null}
{this.props.selectMode ?
<div id="batch-action-bar">
<div id="batch-action-count">已选 {this.props.selectedSeqs.length} 个</div>
<div id="batch-action-buttons">
<a href="#" onClick={e => { e.preventDefault(); this.props.selectedSeqs.length && this.props.onShowBatchForward(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">forward</i>转发
</a>
<a href="#" onClick={e => { e.preventDefault(); this.handleBatchFavorite(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">star_border</i>收藏
</a>
<a href="#" onClick={e => { e.preventDefault(); this.handleBatchDelete(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">delete_outline</i>删除
</a>
</div>
<a href="#" id="batch-action-cancel" onClick={e => { e.preventDefault(); this.props.onExitSelectMode(); }}>取消</a>
</div>
: this.state.unconfirmed ?
<Invitation onAction={this.handleNewChatAcceptance} />
:
<SendMessage
tinode={this.props.tinode}
topicName={this.state.topic}
noInput={!!this.props.forwardMessage}
// relationshipContext.can_send is deliberately NOT part of this -- per the reference
// screenshots (个人对话-拉黑状态/已读状态.png etc.), every non-friend relationship
// state (pending/none/blocked) keeps the composer typable; the restriction shows up as
// a per-message failure reason (chat-message.jsx, keyed off `relationship`) after a
// real send attempt, not as a preemptively grayed-out input. can_send has no other
// reader in the codebase -- this was the only place it was gating anything, and doing
// so here made every failure-reason branch for those relationships unreachable, since
// the composer never let you attempt a send in the first place. isWriter/deleted are
// the real Tinode ACS-level restrictions and are unaffected by this.
disabled={!this.state.isWriter || this.state.deleted}
reply={this.state.reply}
initMessage={this.state.contentToEdit}
@@ -1927,7 +2271,7 @@ class MessagesView extends React.Component {
</>
);
}
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}</div>
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}{riskDialog}</div>
}
return component;
}