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
+372
-28
@@ -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> {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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user