// Panel with a chat. import React, { Suspense } from 'react'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Drafty, Tinode, TheCard } from 'tinode-sdk'; const CallPanel = React.lazy(_ => import('../widgets/call-panel.jsx')); import ChatMessage from '../widgets/chat-message.jsx'; import ContactBadges from '../widgets/contact-badges.jsx'; const DocPreview = React.lazy(_ => import('../widgets/doc-preview.jsx')); import ErrorPanel from '../widgets/error-panel.jsx'; import GroupSubs from '../widgets/group-subs.jsx'; const ImagePreview = React.lazy(_ => import('../widgets/image-preview.jsx')); import Invitation from '../widgets/invitation.jsx'; import LetterTile from '../widgets/letter-tile.jsx'; 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')); import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDIATE_P2P_SUBSCRIPTION, DRAFTY_FR_MIME_TYPE_LEGACY, KEYPRESS_DELAY, MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, 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, getFavorites, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js'; import HashNavigation from '../lib/navigation.js'; import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js'; // Run timer with this frequency (ms) for checking notification queue. const NOTIFICATION_EXEC_INTERVAL = 300; // Scroll distance before [go to latest message] button is shown. const SHOW_GO_TO_LAST_DIST = 100; // Sctoll distance from the top when fetching the page of earlier messages is triggered. const FETCH_PAGE_TRIGGER = 40; const messages = defineMessages({ // 已存在于 src/i18n/*.json(zh: "消息已删除"),这里只是注册引用,不是新增待翻译串 message_deleted: { id: 'message_deleted', defaultMessage: 'message deleted', description: 'Placeholder shown in the timeline where a message was recalled or deleted', }, online_now: { id: 'online_now', defaultMessage: 'online now', description: 'Indicator that the user or topic is currently online', }, last_seen: { id: 'last_seen_timestamp', defaultMessage: 'Last seen: {timestamp}', description: 'Label for the timestamp of when the user or topic was last online' }, not_found: { id: 'title_not_found', defaultMessage: 'Not found', description: 'Title shown when topic is not found' }, channel: { id: 'channel', defaultMessage: 'channel', description: 'Subtitle shown for channels in MessagesView instead of last seen' }, file_attachment_too_large: { id: 'file_attachment_too_large', defaultMessage: 'The file size {size} exceeds the {limit} limit.', description: 'Error message when attachment is too large' }, invalid_content: { id: 'invalid_content', defaultMessage: 'invalid content', description: 'Shown when the message is unreadable' }, editing_message: { id: 'editing_message', defaultMessage: 'Editing', description: 'Title over message editing preview' }, drag_file: { id: 'drag_file', defaultMessage: 'Drag file here', description: 'Prompt on the file drag-n-drop overlay banner' }, self_topic_name: { id: 'self_topic_name', defaultMessage: 'Saved messages', description: 'Name of self topic for UI' }, subscriber_count: { id: 'subscriber_count', defaultMessage: '{count, plural, ' + 'one {{count, number} subscriber} ' + 'two {{count, number} subscribers} ' + 'three {{count, number} subscribers} ' + 'few {{count, number} subscribers} ' + 'many {{count, number} subscribers} ' + 'other {{count, number} subscribers}}', description: 'Count of channel subscribers' }, member_count: { id: 'member_count', defaultMessage: '{count, plural, ' + 'one {{count, number} member} ' + 'two {{count, number} members} ' + 'three {{count, number} members} ' + 'few {{count, number} members} ' + 'many {{count, number} members} ' + 'other {{count, number} members}}', description: 'Count of group topic members' }, cannot_parse_vcard: { id: 'cannot_parse_vcard', defaultMessage: 'Cannot parse vCard file.', description: 'Error message when vCard file cannot be parsed' } }); // Checks if the access permissions are granted but not yet accepted. function isUnconfirmed(acs) { if (acs) { const ex = acs.getExcessive() || ''; return acs.isJoiner('given') && (ex.includes('R') || ex.includes('W')); } return false; } function isPeerRestricted(acs) { if (acs) { const ms = acs.getMissing() || ''; return acs.isJoiner('want') && (ms.includes('R') || ms.includes('W')); } return false; } function shouldPresentCallPanel(callState) { // Show call panel if either: // - call is outgoing (and the client is waiting for the other side to pick up) or, // - call is already in progress. return callState == CALL_STATE_OUTGOING_INITATED || callState == CALL_STATE_IN_PROGRESS; } class MessagesView extends React.Component { constructor(props) { 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.mentionedUsers = []; 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); this.sendFileAttachment = this.sendFileAttachment.bind(this); this.sendAudioAttachment = this.sendAudioAttachment.bind(this); this.sendTheCardAttachment = this.sendTheCardAttachment.bind(this); this.sendKeyPress = this.sendKeyPress.bind(this); this.subscribe = this.subscribe.bind(this); this.handleScrollReference = this.handleScrollReference.bind(this); this.mountDnDEvents = this.mountDnDEvents.bind(this); this.handleScrollEvent = this.handleScrollEvent.bind(this); this.handleDescChange = this.handleDescChange.bind(this); this.handleSubsUpdated = this.handleSubsUpdated.bind(this); this.handleMessageUpdate = this.handleMessageUpdate.bind(this); this.handleAuxUpdate = this.handleAuxUpdate.bind(this); this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this); this.handleInfoReceipt = this.handleInfoReceipt.bind(this); this.handleExpandMedia = this.handleExpandMedia.bind(this); this.handleClosePreview = this.handleClosePreview.bind(this); this.handleCallPanelToggle = this.handleCallPanelToggle.bind(this); this.handleFormResponse = this.handleFormResponse.bind(this); this.handleContextClick = this.handleContextClick.bind(this); 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.loadDraft = this.loadDraft.bind(this); this.handleComposerChange = this.handleComposerChange.bind(this); this.clearDraft = this.clearDraft.bind(this); this.mentionSearch = this.mentionSearch.bind(this); this.mentionSelected = this.mentionSelected.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); this.postReadNotification = this.postReadNotification.bind(this); this.clearNotificationQueue = this.clearNotificationQueue.bind(this); this.goToLatestMessage = this.goToLatestMessage.bind(this); this.handleFileDrop = this.handleFileDrop.bind(this); this.handlePickReply = this.handlePickReply.bind(this); 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); this.isDragEnabled = this.isDragEnabled.bind(this); this.handleDragStart = this.handleDragStart.bind(this); this.handleDragIn = this.handleDragIn.bind(this); this.handleDragOut = this.handleDragOut.bind(this); this.handleDrag = this.handleDrag.bind(this); this.handleDrop = this.handleDrop.bind(this); this.chatMessageRefs = []; this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this); this.getVisibleMessageRange = this.getVisibleMessageRange.bind(this); // Keeps track of the drag event. // Need a counter b/c the browser's 'drag' events may fire multiple times // when the user takes the mouse pointer over the container: // for the component itself and for all nested/child elements. this.dragCounter = 0; this.dndRef = null; this.readNotificationQueue = []; this.readNotificationTimer = null; this.keyPressTimer = null; } getOrCreateMessageRef(seqId) { if (this.chatMessageRefs[seqId]) { return this.chatMessageRefs[seqId]; } const ref = React.createRef(); this.chatMessageRefs[seqId] = ref; return ref; } getVisibleMessageRange(holderRect) { let min = Number.MAX_SAFE_INTEGER, max = -1; let visibilityStatus = false; this.chatMessageRefs.every((ref, seq) => { if (ref.current) { const { top, bottom, height } = ref.current.getBoundingClientRect(); const visible = top <= holderRect.top ? holderRect.top - top <= height : bottom - holderRect.bottom <= height; if (visible) { visibilityStatus = true; min = Math.min(min, seq); max = Math.max(max, seq); } else if (visibilityStatus) { // The remaining elements are no longer visible, no need to iterate them. return false; } } return true; }); return max >= min ? {min: min, max: max} : {min: 0, max: 0}; } componentDidMount() { if (this.messagesScroller) { this.messagesScroller.addEventListener('scroll', this.handleScrollEvent); } // Drag and drop events this.mountDnDEvents(this.dndRef); this.componentSetup({}, {}); getBootstrap().then(data => { this.setState({messagePolicy: data.message_policy || null}); }).catch(_ => {}); } componentWillUnmount() { // componentSetup below has an identical guard for switching from one topic to another during an // active call -- but componentDidUpdate/componentSetup never runs on an actual unmount (leaving // the messaging section entirely for another chapter, or on mobile switching to the conversation // list while displayMobile). Without this, CallPanel's own componentWillUnmount only tears down // local media: it never notifies the peer or resets tinode-web.jsx's top-level callState/ // callTopic, which then stay stuck at CALL_STATE_IN_PROGRESS. Navigating back to the call's topic // later remounts a "zombie" CallPanel -- its start() sees that stale CALL_STATE_IN_PROGRESS and // takes the "we just accepted" branch, which never calls getUserMedia(): no local media, every // control disabled, only hang-up works. if (this.state.rtcPanel) { this.handleCallHangup(this.state.topic, this.props.callSeq); } if (this.messagesScroller) { this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent); } window.clearTimeout(this.toastTimer); // Flush all notifications. this.clearNotificationQueue(); // Drag and drop events if (this.dndRef) { this.dndRef.removeEventListener('dragstart', this.handleDragStart); this.dndRef.removeEventListener('dragenter', this.handleDragIn); this.dndRef.removeEventListener('dragleave', this.handleDragOut); this.dndRef.removeEventListener('dragover', this.handleDrag); this.dndRef.removeEventListener('drop', this.handleDrop); } } componentDidUpdate(prevProps, prevState) { // Scroll last message into view on component update e.g. on message received // or vertical shrinking. if (this.messagesScroller && (prevState.topic != this.state.topic || prevState.maxSeqId != this.state.maxSeqId || prevState.minSeqId != this.state.minSeqId)) { // New message. if (this.state.scrollPosition < SHOW_GO_TO_LAST_DIST) { this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition - this.messagesScroller.offsetHeight; } } if (!this.props.applicationVisible) { // If application is not visible, flush all unsent 'read' notifications. this.clearNotificationQueue(); } else { // Otherwise assume there are unread messages. 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); } componentSetup(prevProps, prevState) { const topic = this.props.tinode ? this.props.tinode.getTopic(this.state.topic) : undefined; if (this.state.topic != prevState.topic) { if (prevState.topic && !Tinode.isNewGroupTopicName(prevState.topic)) { this.leave(prevState.topic); if (prevState.rtcPanel) { this.handleCallHangup(prevState.topic, prevProps.callSeq); } } if (topic) { topic.onData = this.handleMessageUpdate; topic.onAllMessagesReceived = this.handleAllMessagesReceived; topic.onInfo = this.handleInfoReceipt; topic.onMetaDesc = this.handleDescChange; topic.onSubsUpdated = this.handleSubsUpdated; topic.onPres = this.handleSubsUpdated; topic.onAuxUpdated = this.handleAuxUpdate; } this.mentionedUsers = []; this.loadRelationshipContext(topic); this.loadGroupContext(topic); this.loadDraft(topic); } if (topic) { if ((this.state.topic != prevState.topic) || (this.props.myUserId && !prevProps.myUserId)) { // Don't immediately subscribe to a new p2p topic, wait for the first message. const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic); if (topic.isP2PType() && newTopic && !IMMEDIATE_P2P_SUBSCRIPTION) { topic.getMeta(topic.startMetaQuery().withDesc().build()); } else if (this.props.myUserId) { this.subscribe(topic); } } else if (topic.isSubscribed() && this.state.isReader && !prevState.isReader) { // If reader status has changed and data became available. topic.getMeta(topic.startMetaQuery().withLaterData().build()); } else if (!topic.isSubscribed() && this.props.ready && !prevProps.ready) { // Connection restored (e.g. wake from sleep): resubscribe to the currently open topic. this.subscribe(topic); } } } static getDerivedStateFromProps(nextProps, prevState) { let nextState = {}; if (!nextProps.topic) { // Default state: no topic. nextState = { minSeqId: -1, maxSeqId: -1, latestClearId: -1, onlineSubs: [], topic: null, title: '', avatar: null, isVerified: false, isStaff: false, isDangerous: false, deleted: false, docPreview: null, imagePreview: null, imagePostview: null, videoPreview: null, videoPostview: null, rtcPanel: null, minimizedCallPanel: false, typingIndicator: false, scrollPosition: 0, fetchingMessages: false, peerMessagingDisabled: false, channel: false, reply: null, contentToEdit: null, showGoToLastButton: false, dragging: false, pins: [], pinsLoaded: false, selectedPin: 0, subsVersion: 0, relationshipContext: null, groupRoles: null, myGroupRole: null, groupAnnouncement: null, pendingJoinRequests: 0, draftText: null, draftVersion: 0 }; } else if (nextProps.topic != prevState.topic) { const topic = nextProps.tinode.getTopic(nextProps.topic); nextState = { topic: nextProps.topic, deleted: topic._deleted, docPreview: null, imagePreview: null, imagePostview: null, videoPreview: null, videoPostview: null, rtcPanel: null, minimizedCallPanel: false, typingIndicator: false, scrollPosition: 0, fetchingMessages: false, showGoToLastButton: false, contentToEdit: null, dragging: false, selectedPin: 0, relationshipContext: null, groupRoles: null, myGroupRole: null, groupAnnouncement: null, pendingJoinRequests: 0, draftText: null, draftVersion: 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) { if (nextProps.forwardMessage.content) { const content = nextProps.forwardMessage.content; const size = content?.length || (content?.size || 0); nextState.docPreview = { object: content, type: nextProps.forwardMessage.type, size: size }; } else { // We are forwarding a message. Show preview. nextState.reply = { content: nextProps.forwardMessage.preview, seq: null }; } } else { nextState.reply = null; } if (topic) { // Topic exists. const subs = []; if (nextProps.connected) { topic.subscribers((sub) => { if (sub.online && sub.user != nextProps.myUserId) { subs.push(sub); } }); } Object.assign(nextState, { onlineSubs: subs }); if (topic.isSelfType()) { Object.assign(nextState, { title: nextProps.intl.formatMessage(messages.self_topic_name), avatar: true }); } else if (topic.public) { Object.assign(nextState, { title: topicDisplayName(topic.name, topic.public, ''), avatar: makeImageUrl(topic.public.photo) }); } else { Object.assign(nextState, { title: '', avatar: null }); } const peer = topic.p2pPeerDesc(); if (peer) { Object.assign(nextState, { peerMessagingDisabled: isPeerRestricted(peer.acs) }); } else if (prevState.peerMessagingDisabled) { Object.assign(nextState, { peerMessagingDisabled: false }); } Object.assign(nextState, { minSeqId: topic.minMsgSeq(), maxSeqId: topic.maxMsgSeq(), latestClearId: topic.maxClearId(), channel: topic.isChannelType(), pins: (topic.aux('pins') || []).slice(), pinsLoaded: false }); if (nextProps.callTopic == topic.name && shouldPresentCallPanel(nextProps.callState)) { nextState.rtcPanel = nextProps.callTopic; } } else { // Invalid topic. Object.assign(nextState, { minSeqId: -1, maxSeqId: -1, latestClearId: -1, onlineSubs: [], title: '', avatar: null, peerMessagingDisabled: false, channel: false, pins: [], pinsLoaded: false }); } } else { // We are still in same topic. Show the call panel if necessary. if (nextProps.callTopic == prevState.topic && !prevState.rtcPanel && shouldPresentCallPanel(nextProps.callState)) { nextState.rtcPanel = nextProps.callTopic; } } if (nextProps.acs) { if (nextProps.acs.isWriter() != prevState.isWriter) { nextState.isWriter = !prevState.isWriter; } if (nextProps.acs.isReader() != prevState.isReader) { nextState.isReader = !prevState.isReader; } if (nextProps.acs.isAdmin() != prevState.isAdmin) { nextState.isAdmin = !prevState.isAdmin; } if (!nextProps.acs.isReader('given') != prevState.readingBlocked) { nextState.readingBlocked = !prevState.readingBlocked; } if (nextProps.acs.isSharer() != prevState.isSharer) { nextState.isSharer = !prevState.isSharer; } } else { if (prevState.isWriter) { nextState.isWriter = false; } if (prevState.isReader) { nextState.isReader = false; } if (prevState.isAdmin) { nextState.isAdmin = false; } if (!prevState.readingBlocked) { nextState.readingBlocked = true; } if (prevState.isSharer) { nextState.isSharer = false; } } if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) { nextState.unconfirmed = !prevState.unconformed; } // Clear subscribers online when there is no connection. if (!nextProps.connected && prevState.onlineSubs && prevState.onlineSubs.length > 0) { nextState.onlineSubs = []; } return nextState; } subscribe(topic) { if (topic.isSubscribed() || !this.props.ready) { return; } // Is this a new topic? const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic); // Don't request the tags. They are useless unless the user // is the owner and is editing the topic. let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux(); if (this.state.isReader || newTopic) { // Reading is either permitted or we don't know because it's a new topic. Ask for messages. getQuery = getQuery.withLaterData(MESSAGES_PAGE); if (this.state.isReader) { getQuery = getQuery.withLaterDel(); } // And show "loading" spinner. this.setState({ fetchingMessages: true }); } const setQuery = newTopic ? this.props.newTopicParams : undefined; topic.subscribe(getQuery.build(), setQuery) .then(ctrl => { if (ctrl.code == 303) { // Redirect to another topic requested. HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic)); return; } if (this.state.topic != ctrl.topic) { this.setState({topic: ctrl.topic}); } if (this.state.deleted) { this.setState({deleted: false}); } this.props.onNewTopicCreated(this.props.topic, ctrl.topic); // If there are unsent messages (except hard-failed and video call messages), // try sending them now. Hard-failed and unsent video call messages will be dropped. let discard = []; topic.queuedMessages(pub => { if (pub._sending) { return; } if (pub._fatal || (pub.head && pub.head.webrtc)) { // Filter out unsent failed & video call messages. discard.push(pub.seq); return; } if (topic.isSubscribed()) { this.retrySend(pub); } }); if (discard.length > 0) { topic.delMessagesList(discard, true); } }) .catch(err => { console.error("Failed subscription to", this.state.topic, err); this.props.onError(err.message, 'err'); const blankState = MessagesView.getDerivedStateFromProps({}, {}); blankState.title = this.props.intl.formatMessage(messages.not_found); this.setState(blankState); }); } leave(oldTopicName) { if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) { return; } const oldTopic = this.props.tinode.getTopic(oldTopicName); if (oldTopic && oldTopic.isSubscribed()) { oldTopic.leave(false) .catch(_ => { /* do nothing here */ }) .finally(_ => { // We don't care if the request succeeded or failed. // The topic is dead regardless. this.setState({fetchingMessages: false}); oldTopic.onData = undefined; oldTopic.onAllMessagesReceived = undefined; oldTopic.onInfo = undefined; oldTopic.onMetaDesc = undefined; oldTopic.onSubsUpdated = undefined; oldTopic.onPres = undefined; oldTopic.onAuxUpdated = undefined; }); } } // Don't use React.createRef as the ref.current is not available in componentDidMount in this component. handleScrollReference(node) { if (node) { node.addEventListener('scroll', this.handleScrollEvent); this.messagesScroller = node; this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition - this.messagesScroller.offsetHeight; } } // Get older messages and show/hide [go to latest message] button. handleScrollEvent(event) { const pos = event.target.scrollHeight - event.target.scrollTop - event.target.offsetHeight; this.setState({ scrollPosition: pos, // Show [go to latest message] if far enough from bottom and scrolling down. showGoToLastButton: (pos > SHOW_GO_TO_LAST_DIST) && (pos < this.state.scrollPosition), }); if (this.state.fetchingMessages || this.processingScrollEvent) { return; } if (event.target.scrollTop <= FETCH_PAGE_TRIGGER) { const topic = this.props.tinode.getTopic(this.state.topic); if (topic && topic.isSubscribed()) { this.processingScrollEvent = true; const {min, max} = this.getVisibleMessageRange(event.target.getBoundingClientRect()); const gaps = topic.msgHasMoreMessages(min, max, false); if (gaps.length > 0) { this.setState({fetchingMessages: true}, _ => { topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max) .catch(err => this.props.onError(err.message, 'err')) .finally(_ => this.setState({fetchingMessages: false})); }); } } } this.processingScrollEvent = false; } /* Mount drag and drop events */ mountDnDEvents(dnd) { if (dnd) { dnd.addEventListener('dragstart', this.handleDragStart); dnd.addEventListener('dragenter', this.handleDragIn); dnd.addEventListener('dragleave', this.handleDragOut); dnd.addEventListener('dragover', this.handleDrag); dnd.addEventListener('drop', this.handleDrop); this.dndRef = dnd; } } goToLatestMessage() { this.setState({scrollPosition: 0}); if (this.messagesScroller) { this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.messagesScroller.offsetHeight; } } handleDescChange(desc) { if (Tinode.isSelfTopicName(this.props.topic)) { this.setState({ title: this.props.intl.formatMessage(messages.self_topic_name), avatar: true }); } else if (desc.public) { this.setState({ title: topicDisplayName(this.props.topic, desc.public, ''), avatar: makeImageUrl(desc.public.photo) }); } else { this.setState({ title: '', avatar: null }); } if (desc.acs) { this.setState({ isWriter: desc.acs.isWriter(), isReader: desc.acs.isReader(), isAdmin: desc.acs.isAdmin(), readingBlocked: !desc.acs.isReader('given'), unconfirmed: isUnconfirmed(desc.acs), }); } } postReadNotification(seq) { // Ignore notifications if the app is invisible or topic is not yet available. if (!this.props.applicationVisible || !this.state.topic) { return; } // Set up the timer if it's not running already. if (!this.readNotificationTimer) { this.readNotificationTimer = setInterval(_ => { if (this.readNotificationQueue.length == 0) { // Shut down the timer if the queue is empty. clearInterval(this.readNotificationTimer); this.readNotificationTimer = null; return; } let seq = -1; while (this.readNotificationQueue.length > 0) { const n = this.readNotificationQueue[0]; if (n.topicName != this.state.topic) { // Topic has changed. Drop the notification. this.readNotificationQueue.shift(); continue; } const now = new Date(); if (n.sendAt <= now) { // Remove expired notification from queue. this.readNotificationQueue.shift(); if (n.seq == 0 || Tinode.isServerAssignedSeq(n.seq)) { seq = Math.max(seq, n.seq); } } else { break; } } // Send only one notification for the whole batch of messages. if (seq >= 0) { const topic = this.props.tinode.getTopic(this.state.topic); if (topic) { try { topic.noteRead(seq); } catch (err) { console.error("Failed to send read notification", err); } } } }, NOTIFICATION_EXEC_INTERVAL); } const now = new Date(); this.readNotificationQueue.push({ topicName: this.state.topic, seq: seq, sendAt: now.setMilliseconds(now.getMilliseconds() + READ_DELAY) }); } // Clear notification queue and timer. clearNotificationQueue() { this.readNotificationQueue = []; if (this.readNotificationTimer) { clearInterval(this.readNotificationTimer); this.readNotificationTimer = null; } } handleSubsUpdated() { if (this.state.topic) { const subs = []; const topic = this.props.tinode.getTopic(this.state.topic); topic.subscribers(sub => { if (sub.online && sub.user != this.props.myUserId) { subs.push(sub); } }); const newState = {onlineSubs: subs, subsVersion: this.state.subsVersion + 1}; const peer = topic.p2pPeerDesc(); if (peer) { Object.assign(newState, { peerMessagingDisabled: isPeerRestricted(peer.acs) }); } else if (this.state.peerMessagingDisabled) { Object.assign(newState, { peerMessagingDisabled: false }); } this.setState(newState); } } // The 'msg' could be false-ish if some message ranges were deleted. handleMessageUpdate(msg) { if (!this.state.topic) { return; } const topic = this.props.tinode.getTopic(this.state.topic); if (!msg) { // msg could be null if one or more messages were deleted. // Updating state to force redraw. this.setState({latestClearId: topic.maxClearId()}); return; } clearTimeout(this.keyPressTimer) this.setState({maxSeqId: topic.maxMsgSeq(), minSeqId: topic.minMsgSeq(), typingIndicator: false}, _ => { // Scroll to the bottom if the message is added to the end of the message // list if already at the bottom, otherwise show [go to latest] button. // Implemented as a callback to be sure the scroll height has been updated. if (topic.isNewMessage(msg.seq)) { if (this.state.scrollPosition > SHOW_GO_TO_LAST_DIST) { this.setState({showGoToLastButton: true}); } else { this.goToLatestMessage(); } } else { if (this.messagesScroller) { this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition - this.messagesScroller.offsetHeight; } } }); // Aknowledge messages except own messages. They are // automatically assumed to be read and recived. const status = topic.msgStatus(msg, true); if (status >= Tinode.MESSAGE_STATUS_SENT && msg.from != this.props.myUserId) { this.postReadNotification(msg.seq); } } handleAllMessagesReceived(count) { this.setState({fetchingMessages: false}); if (!count) { // All messages received. Nothing left to receive. const topic = this.props.tinode.getTopic(this.state.topic); if (topic) { // Send read notification for the absolute last message. this.postReadNotification(topic.seq); } return; } // Load pinned message too (if any); if (!this.state.pinsLoaded) { // This needs to be guarded to avoid an infinite loop. // The loop may happen if some pinned messages are deleted. const topic = this.props.tinode.getTopic(this.state.topic); this.setState({pinsLoaded: true}, _ => { topic.getPinnedMessages(); }); } else { this.setState({pinsLoaded: false}); } } handleAuxUpdate(aux) { const pins = (aux['pins'] || []).slice(); let selectedPin = this.state.selectedPin; if (pins.length > this.state.pins.length) { // New pins added, show the latest. selectedPin = 0; } else if (selectedPin >= pins.length) { selectedPin = Math.max(0, pins.length - 1); } this.setState({pins: pins, selectedPin: selectedPin}); } handleInfoReceipt(info) { switch (info.what) { case 'kp': { clearTimeout(this.keyPressTimer); this.keyPressTimer = setTimeout(_ => this.setState({typingIndicator: false}), KEYPRESS_DELAY + 1000); if (!this.state.typingIndicator) { this.setState({typingIndicator: true}); } break; } case 'read': case 'recv': // Redraw due to changed recv/read status. this.forceUpdate(); break; default: console.info("Other change in topic: ", info.what); } } handleExpandMedia(content) { if (!content) { return; } if (content.video) { this.setState({videoPostview: content}); } else { this.setState({imagePostview: content}); } } handleClosePreview() { if (this.state.imagePreview && this.state.imagePreview.url) { URL.revokeObjectURL(this.state.imagePreview.url); } if (this.state.videoPreview && this.state.videoPreview.url) { URL.revokeObjectURL(this.state.videoPreview.url); } this.setState({ imagePostview: null, imagePreview: null, docPreview: null, videoPreview: null, videoPostview: null, }); this.props.onCancelForwardMessage(); } handleFormResponse(action, text, data) { if (action == 'pub') { this.sendMessage(Drafty.attachJSON(Drafty.parse(text), data)); } else if (action == 'url') { const url = new URL(data.ref); const params = url.searchParams; for (let key in data.resp) { if (data.resp.hasOwnProperty(key)) { params.set(key, data.resp[key]); } } ['name', 'seq'].forEach(key => { if (data[key]) { params.set(key, data[key]); } }); params.set('uid', this.props.myUserId); params.set('topic', this.state.topic); url.search = params; window.open(url, '_blank'); } else { console.info("Unknown action in form", action); } } handleContextClick(e) { e.preventDefault(); e.stopPropagation(); this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX }); } handleShowMessageContextMenu(params, messageSpecificMenuItems) { if (params.userFrom == 'chan') { params.userFrom = this.state.topic; params.userName = this.state.title; } params.topicName = this.state.topic; 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. if (topic.isSelfType()) { // Hard-delete with plain [Delete] title. menuItems.push('message_delete_generic'); } else { if (!topic.isChannelType()) { // P2P or normal group: always allow to soft-delete. menuItems.push('message_delete'); } const acs = topic.getAccessMode(); if (acs && acs.isDeleter()) { // Owner can always hard-delete regardless of age. let canDelete = acs.isOwner(); if (!canDelete) { // Not owner: check message age if configured on the server. const maxDelAge = this.props.tinode.getServerParam(Tinode.MSG_DELETE_AGE, 0) | 0; // Make sure the message is not too old. This check is here just to make the UI // more user-friendly. The server will enforce the limit regardless of this. canDelete = maxDelAge == 0 || (maxDelAge > 0 && params.timestamp ? params.timestamp.getTime() > (new Date().getTime() - maxDelAge * 1000) : false); } if (canDelete) { // [Delete for All] title. menuItems.push('message_delete_hard'); } } } } menuItems.push('menu_item_select'); this.props.showContextMenu(params, menuItems); } handleNewChatAcceptance(action) { this.props.onNewChat(this.state.topic, action); } handleEnablePeer(e) { e.preventDefault(); 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(_ => {}); } // 4.2-4.3 @提醒:只对群话题开放(私聊提@没有意义,也没有可选的"其他成员")。返回给 // send-message.jsx 的选择器渲染,真正的 uid 解析在 mentionSelected 里记下来,供发送时 // applyMentionResolution 使用。 mentionSearch(query) { const topic = this.props.tinode ? this.props.tinode.getTopic(this.state.topic) : null; if (!topic || !topic.isGroupType()) { return Promise.resolve([]); } return getGroupMembers(this.state.topic, {mentionable: true, query}).then(data => data.items || []); } mentionSelected(uid, name) { this.mentionedUsers = (this.mentionedUsers || []).filter(m => m.name !== name); this.mentionedUsers.push({uid, name}); } // 4.1 跨话题草稿:话题一切换就去问服务端这个会话有没有存过草稿。原生话题名(P2P 用对方 // user_id)直接可用,接口内部自己会做订阅存在性校验,不需要额外归一化。 loadDraft(topic) { if (!topic) { return; } const topicName = topic.name; getTopicDraft(topicName).then(data => { if (this.state.topic != topicName) { return; } this.setState({draftText: data.content || '', draftVersion: data.version || 0}); }).catch(_ => {}); } // 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的 // 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下 // SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。 // // 定时器按话题分开存(this.draftSaveTimers 是个 map),不能像原来那样用单个共享句柄—— // 那样话题A打字触发的600ms保存,会在300ms后切到话题B打字时被 clearTimeout 直接取消掉, // A刚打的字就再也不会发给服务器了。 // // version 在这里(调用的当下,this.state.topic 必然还是 topicName,因为 onMessageChange // 只有当前激活话题的 SendMessage 实例会触发)同步捕获进闭包,不能等定时器触发时才去读 // this.state.draftVersion——那时用户很可能已经切到别的话题,届时重新读到的要么是无关话题 // 的版本号,要么在判断不匹配时退化成硬编码的 0。后端 PUT .../draft 对 version=0 是"无条件 // 跳过乐观锁校验直接覆盖"而不是"仅当草稿真的不存在时才创建"(见 // docs/IM-后端问题清单-2026-09-04.md),这个 0 一旦被真的发出去,可能无声覆盖掉这段时间里 // 别的标签页/设备写入的更新草稿。 handleComposerChange(text) { if (this.state.reply && this.state.reply.editing) { return; } if (this.props.forwardMessage) { return; } const topicName = this.state.topic; if (!topicName) { return; } const version = this.state.draftVersion || 0; this.draftSaveTimers = this.draftSaveTimers || {}; if (this.draftSaveTimers[topicName]) { window.clearTimeout(this.draftSaveTimers[topicName]); } this.draftSaveTimers[topicName] = window.setTimeout(() => { delete this.draftSaveTimers[topicName]; updateTopicDraft(topicName, text, [], version).then(d => { if (this.state.topic == topicName) { this.setState({draftVersion: d.version}); } }).catch(_ => {}); }, 600); } // 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。 // 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实 // 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。 // knownVersion 由调用方(sendMessage)在发送发起的那一刻同步捕获传入,原因同上——避免在 // 发送成功回调触发时(用户很可能已经切换话题)重新派生出不可靠的版本号。 clearDraft(topicName, knownVersion) { if (this.draftSaveTimers && this.draftSaveTimers[topicName]) { window.clearTimeout(this.draftSaveTimers[topicName]); delete this.draftSaveTimers[topicName]; } const version = knownVersion != null ? knownVersion : (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0; updateTopicDraft(topicName, '', [], version).then(d => { if (this.state.topic == topicName) { this.setState({draftText: '', draftVersion: d.version}); } }).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(); // `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx) // return to this chat instead of always landing on group-governance. let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic); hash = HashNavigation.addUrlParam(hash, 'from', 'chat'); HashNavigation.navigateTo(hash); } 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; const seqs = this.props.selectedSeqs.slice(); favoriteMessages(seqs.map(seq => ({topic: topicName, seq}))).then(() => { // `result.failed` can't be trusted here: the batch-create endpoint reuses one request-level // Idempotency-Key across every message in the batch (docs/IM-后端问题清单-2026-09-04.md, // 新增1), so every item after the first silently no-ops via ON DUPLICATE KEY UPDATE and gets // folded into `existing_count` -- indistinguishable, from this response alone, from a message // that really was already favorited. Re-reading the actual favorites list and checking which // of the messages we just asked for really landed sidesteps that ambiguity entirely. return getFavorites({type: 'all', limit: 100}).then(data => { const items = (data && data.items) || []; const landed = new Set(items .filter(it => it.source && it.source.topic == topicName) .map(it => String(it.source.seq))); const missing = seqs.filter(seq => !landed.has(String(seq))); if (missing.length === seqs.length) { this.props.onError('收藏失败', 'err'); } else if (missing.length) { this.showToast(`已收藏 ${seqs.length - missing.length}/${seqs.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()) { 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. // 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. // Mutates a parsed Drafty document in place, upgrading any auto-detected "@name" MN entity whose // raw matched text equals a name the user actually picked from the mention dropdown into a real, // uid-bearing mention. Entities that don't match anything tracked (a literal "@word" the user just // typed, or an edited/mismatched name) are left with their raw text value -- Tinode's own renderer // already falls back to displaying that gracefully, this only upgrades the ones it safely can. applyMentionResolution(parsed) { if (!parsed || !Array.isArray(parsed.ent) || !this.mentionedUsers || !this.mentionedUsers.length) { return; } parsed.ent.forEach(e => { if (e.tp === 'MN' && e.data && typeof e.data.val === 'string') { const match = this.mentionedUsers.find(m => m.name === e.data.val); if (match) { e.data.val = match.uid; } } }); } 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. msg = this.props.forwardMessage.msg; head = this.props.forwardMessage.head; this.handleCancelReply(); } else if (this.state.reply) { if (this.state.reply.editing) { if (msg == this.state.contentToEdit) { // Message unchanged. this.handleCancelReply(); return; } // Editing an existing message. head = {replace: ':' + this.state.reply.seq}; } else if (this.state.reply.content) { // Replying to a message in this topic. // Turn it into Drafty so we can make a quoted Drafty object later. head = {reply: '' + this.state.reply.seq}; if (typeof msg == 'string') { msg = Drafty.parse(msg); this.applyMentionResolution(msg); } msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg); } this.handleCancelReply(); } else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) { // 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但 // data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val => // ({val: val.slice(1)})),不是 uid——那是纯文本解析器,不可能凭空知道 uid。真正的 uid // 只在用户从选择器里点选时才知道,记在 this.mentionedUsers 里;这里按显示名回填,把 // 自动识别出的占位符换成真的 uid。没有任何选过的提醒时完全不碰 msg,维持字符串原样, // 交给 SDK 自己在更底层 parse(跟这次改动之前完全一样的行为,不引入任何差异)。 const parsed = Drafty.parse(msg); this.applyMentionResolution(parsed); msg = parsed; } // Was only cleared on topic switch -- picking someone from the @提醒 picker once left them // resolvable for every later message in the same topic too, so literal text "@张三" typed by // hand, pasted, or quoted from someone else in a LATER, unrelated message would silently turn // into a real mention of whoever was last picked under that name. A retry of a failed send // (retrySend) reuses the already-resolved Drafty content stored on the failed pub, not this // array, so clearing it here doesn't affect retries. this.mentionedUsers = []; if (typeHint) { head = {...(head || {}), 'x-im-type': typeHint}; } const topicForDraft = this.state.topic; // Captured now, not re-read inside the .then() below -- by the time send actually resolves // the user may have switched topics, and clearDraft falling back to a hardcoded 0 runs into // the same backend version=0-skips-the-lock issue explained in handleComposerChange above. const draftVersionAtSend = this.state.draftVersion || 0; const doSend = () => { const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); // Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or // reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing. if (originalText != null && result && typeof result.then == 'function' && topicForDraft) { result.then(() => this.clearDraft(topicForDraft, draftVersionAtSend)); } return result; }; // 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. retrySend(pub) { this.props.sendMessage(pub.content, undefined, undefined, pub.head) .then(_ => { // All good. Remove the original message draft from the cache. const topic = this.props.tinode.getTopic(this.state.topic); topic.delMessagesList([pub.seq], true); }); } // sendFileAttachment sends the file as Drafty message: // - if file is too large, upload it and send a s link. // - if file is small enough, just send it in-band. sendFileAttachment(file) { // Server-provided limit reduced for base64 encoding and overhead. const maxInbandAttachmentSize = (this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0; if (TheCard.isFileSupported(file.type, file.name) || file.type == TheCard.contentType) { // This is a vCard file, try to convert and send as TheCard. if (this.sendTheCardAttachment(file, maxInbandAttachmentSize)) { // Successfully sent as TheCard. return; } // Sending as TheCard failed, fall through to sending as generic file attachment. } // If the attachment is a JSON file, then use 'application/octet-stream' instead of 'application/json'. // This is a temporary workaround for the collision with the 'application/json' MIME type of form responses. // Remove this code in 2026 or so. const jsonMimeConverter = (fileType) => fileType === DRAFTY_FR_MIME_TYPE_LEGACY ? 'application/octet-stream' : fileType; if (file.size > maxInbandAttachmentSize) { // Too large to send inband - uploading out of band and sending as a link. const uploader = createChatUploader(this.props.tinode); if (!uploader) { this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); return; } const uploadCompletionPromise = uploader.upload(file); const msg = Drafty.attachFile(null, { mime: jsonMimeConverter(file.type), filename: file.name, size: file.size, urlPromise: uploadCompletionPromise }); // Pass data and the uploader to the TinodeWeb. this.sendMessage(msg, uploadCompletionPromise, uploader, 'file'); } else { // Small enough to send inband. fileToBase64(file) .then(b64 => this.sendMessage(Drafty.attachFile(null, { mime: jsonMimeConverter(b64.mime), data: b64.bits, filename: b64.name, size: file.size }), undefined, undefined, 'file')) .catch(err => this.props.onError(err.message, 'err')); } } // Convert attached vCard to TheCard and send. sendTheCardAttachment(file, maxInbandAttachmentSize) { if (file.size > maxInbandAttachmentSize) { // Cannot convert to TheCard, send as a generic file attachment instead. return false; } if (file.type == TheCard.contentType) { // Already TheCard, no need to convert. this.sendMessage(Drafty.appendTheCard(null, file.object)); return true; } importVCard(file) .then(card => { this.sendMessage(Drafty.appendTheCard(null, card)); }) .catch(err => { this.props.onError(this.props.intl.formatMessage(messages.cannot_parse_vcard, {error: err.message}), 'err'); }); return true; } // handleAttachFile method is called when [Attach file] button is clicked: launch attachment preview. handleAttachFile(file) { const maxExternAttachmentSize = this.props.tinode.getServerParam('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE); if (file.size > maxExternAttachmentSize) { // Too large. this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large, {size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err'); } else { this.setState({ docPreview: { file: file, name: file.name, size: file.size, type: file.type } }); } } handleCallHangup(topic, seq) { this.props.onVideoCallClosed(); this.setState({ rtcPanel: null }); this.props.onCallHangup(topic, seq); } handleCallPanelToggle() { this.setState({minimizedCallPanel: !this.state.minimizedCallPanel}); } // sendImageAttachment sends the image bits as Drafty message. sendImageAttachment(caption, blob) { const mime = this.state.imagePreview.mime; const width = this.state.imagePreview.width; const height = this.state.imagePreview.height; const fname = this.state.imagePreview.filename; // Server-provided limit reduced for base64 encoding and overhead. const maxInbandAttachmentSize = (this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0; if (blob.size > maxInbandAttachmentSize) { // Too large to send inband - uploading out of band and sending as a link. const uploader = createChatUploader(this.props.tinode); if (!uploader) { this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); return; } const uploadCompletionPromise = uploader.upload(blob); // Make small preview to show while uploading. imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false) // Convert tiny image into base64 for serialization and previewing. .then(scaled => blobToBase64(scaled.blob)) .then(b64 => { let msg = Drafty.insertImage(null, 0, { mime: mime, _tempPreview: b64.bits, // This preview will not be serialized. bits: b64.bits, // Image thumbnail. width: width, height: height, filename: fname, size: blob.size, urlPromise: uploadCompletionPromise }); if (caption) { msg = Drafty.appendLineBreak(msg); msg = Drafty.append(msg, Drafty.parse(caption)); } // Pass data and the uploader to the TinodeWeb. this.sendMessage(msg, uploadCompletionPromise, uploader, 'image'); }) .catch(err => this.props.onError(err, 'err')); return; } // Send the image inband if it's not too big. The image has been scaled already // in image preview. blobToBase64(blob) .then(b64 => { let msg = Drafty.insertImage(null, 0, { mime: b64.mime, bits: b64.bits, width: width, height: height, filename: fname, size: blob.size }); if (caption) { msg = Drafty.appendLineBreak(msg); msg = Drafty.append(msg, Drafty.parse(caption)); } this.sendMessage(msg, undefined, undefined, 'image'); }); } // sendVideoAttachment sends the video bits as Drafty message. sendVideoAttachment(caption, videoBlob, previewBlob, params) { const width = params.width; const height = params.height; // Server-provided limit reduced for base64 encoding and overhead. const maxInbandAttachmentSize = (this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0; const uploads = []; 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 = createChatUploader(this.props.tinode); if (!uploader) { this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); return; } uploads[0] = videoBlob.size > maxInbandAttachmentSize * 0.675 ? uploader.upload(videoBlob) : null; uploads[1] = previewBlob.size > maxInbandAttachmentSize * 0.275 ? uploader.upload(previewBlob) : null; } if (uploads.length == 0) { // Both video and preview are small enough to send inband. Promise.all([blobToBase64(videoBlob), blobToBase64(previewBlob)]) .then(b64s => { const [v64, i64] = b64s; let msg = Drafty.insertVideo(null, 0, { mime: v64.mime, bits: v64.bits, preview: i64.bits, premime: i64.mime, width: width, height: height, duration: params.duration, filename: params.name, size: videoBlob.size }); if (caption) { msg = Drafty.appendLineBreak(msg); msg = Drafty.append(msg, Drafty.parse(caption)); } this.sendMessage(msg, undefined, undefined, 'video'); }); return; } const uploadCompletionPromise = Promise.all(uploads); const b64conv = []; // Small video converted to base64. b64conv[0] = uploads[0] ? null : blobToBase64(videoBlob); // Full-size preview fits inline. b64conv[1] = uploads[1] ? null : imageScaled(previewBlob, MAX_IMAGE_DIM, MAX_IMAGE_DIM, -1, false) .then(scaled => blobToBase64(scaled.blob)); // Small preview to show while uploading. b64conv[2] = imageScaled(previewBlob, VIDEO_PREVIEW_DIM, VIDEO_PREVIEW_DIM, -1, false) .then(scaled => blobToBase64(scaled.blob)); // Convert tiny image into base64 for serialization and previewing. Promise.all(b64conv) .then(b64s => { const [video, img, preview] = b64s; let msg = Drafty.insertVideo(null, 0, { mime: params.mime, bits: video ? video.bits : null, _tempPreview: preview.bits, preview: img ? img.bits : preview.bits, premime: img ? img.mime : preview.mime, width: width, height: height, duration: params.duration, filename: params.name, size: videoBlob.size, urlPromise: uploadCompletionPromise }); if (caption) { msg = Drafty.appendLineBreak(msg); msg = Drafty.append(msg, Drafty.parse(caption)); } // Pass data and the uploader to the TinodeWeb. this.sendMessage(msg, uploadCompletionPromise, uploader, 'video'); }) .catch(err => this.props.onError(err.message, 'err')); } // handleAttachImageOrVideo method is called when [Attach image or video] button is clicked: launch image or video preview. handleAttachImageOrVideo(file) { const maxExternAttachmentSize = this.props.tinode.getServerParam('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE); if (file.type.startsWith('video/')) { this.setState({videoPreview: { url: URL.createObjectURL(file), blob: file, filename: file.name, size: file.size, mime: file.type }}); return; } // Get image dimensions and size, optionally scale it down. imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false) .then(scaled => { this.setState({imagePreview: { url: URL.createObjectURL(scaled.blob), blob: scaled.blob, filename: scaled.name, width: scaled.width, height: scaled.height, size: scaled.blob.size, mime: scaled.mime }}); }).catch(err => { this.props.onError(err.message, 'err'); }); } // handleFileDrop is called when the user drags & drops a file upon the message view. handleFileDrop(files) { if (!files || files.length == 0) { return; } const file = files[0]; if (file.type && file.type.startsWith('image/')) { this.handleAttachImageOrVideo(file); } else { this.handleAttachFile(file); } } // sendAudioAttachment sends audio bits inband as Drafty message (no preview). sendAudioAttachment(url, preview, duration) { fetch(url) .then(result => result.blob()) .then(blob => { // Server-provided limit reduced for base64 encoding and overhead. 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 = createChatUploader(this.props.tinode); if (!uploader) { this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); return; } const uploadCompletionPromise = uploader.upload(blob); const msg = Drafty.appendAudio(null, { mime: blob.type, size: blob.size, duration: duration, preview: preview, urlPromise: uploadCompletionPromise }); // Pass data and the uploader to the TinodeWeb. this.sendMessage(msg, uploadCompletionPromise, uploader, 'voice'); } else { // Small enough to send inband. blobToBase64(blob) .then(b64 => { this.sendMessage(Drafty.appendAudio(null, { mime: b64.mime, bits: b64.bits, size: blob.size, duration: duration, preview: preview, }), undefined, undefined, 'voice') }) } }) .catch(err => {this.props.onError(err.message, 'err')});; } handleCancelUpload(seq, uploader) { const topic = this.props.tinode.getTopic(this.state.topic); const found = topic.findMessage(seq); if (found) { found._cancelled = true; } uploader.cancel(); } // seq: seq ID of the source message // context: message content. // senderId: UID of the sender of the source message. // senderName: full name of the sender of the original message. handlePickReply(seq, content, senderId, senderName) { if (!seq || !content) { this.setState({reply: null}); return; } content = typeof content == 'string' ? Drafty.init(content) : content; if (Drafty.isValid(content)) { content = Drafty.replyContent(content, QUOTED_REPLY_LENGTH); } else { // /!\ invalid content. content = Drafty.append(Drafty.init('\u26A0 '), Drafty.wrapInto(this.props.intl.formatMessage(messages.invalid_content), 'EM')); } this.setState({ reply: { content: Drafty.quote(senderName, senderId, content), seq: seq } }); this.props.onCancelForwardMessage(); } // seq: seq ID of the message to edit. // context: message content. handleEditMessage(seq, content) { if (!seq || !content) { this.setState({reply: null}); return; } content = typeof content == 'string' ? Drafty.init(content) : content; const editable = Drafty.toMarkdown(content); if (Drafty.isValid(content)) { content = Drafty.replyContent(content, EDIT_PREVIEW_LENGTH); } else { // /!\ invalid content. content = Drafty.append(Drafty.init('\u26A0 '), Drafty.wrapInto(this.props.intl.formatMessage(messages.invalid_content), 'EM')); } this.setState({ reply: { content: Drafty.quote(this.props.intl.formatMessage(messages.editing_message), null, content), seq: seq, editing: true }, contentToEdit: editable }); this.props.onCancelForwardMessage(); } handleCancelReply() { this.setState({reply: null, contentToEdit: null}); this.props.onCancelForwardMessage(); } handleQuoteClick(replyToSeq) { const ref = this.getOrCreateMessageRef(replyToSeq); if (ref && ref.current) { ref.current.scrollIntoView({block: "center", behavior: "smooth"}); ref.current.classList.add('flash'); setTimeout(_ => {ref.current.classList.remove('flash')} , 1000); } else { // Most commonly hit when the target message was deleted/recalled after being found by // Ch.7 chat-history search but before this click -- never gets a ref // (see jumpToMessage below), so without this the click did nothing visible at all. console.error("Unresolved message ref", replyToSeq); if (this.props.onError) { this.props.onError('该消息已被删除或撤回', 'err'); } } } // 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); } isDragEnabled() { return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage && !this.state.peerMessagingDisabled; } handleDragStart(e) { e.preventDefault(); e.stopPropagation(); e.dataTransfer.clearData(); } handleDragIn(e) { e.preventDefault(); e.stopPropagation(); this.dragCounter++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { this.setState({dragging: true}); } } handleDragOut(e) { e.preventDefault(); e.stopPropagation(); this.dragCounter--; if (this.dragCounter <= 0) { this.setState({dragging: false}); } } handleDrag(e) { e.preventDefault(); e.stopPropagation(); } handleDrop(e) { e.preventDefault(); e.stopPropagation(); this.setState({dragging: false}); if (this.isDragEnabled() && e.dataTransfer.files && e.dataTransfer.files.length > 0) { this.handleFileDrop(e.dataTransfer.files); this.dragCounter = 0; } } render() { const {formatMessage} = this.props.intl; // P2P call is an overlay, not a separate component because it cannot be // re-mounted between full screen/minimized. const overlay = this.state.rtcPanel && }> ; const riskDialog = this.state.riskDialog && ; let component; if (!this.state.topic) { component = ; } else { let component2; if (this.state.imagePreview) { // Preview image before sending. component2 = ( }> ); } else if (this.state.videoPreview) { // Preview video. component2 = }> ; } else if (this.state.imagePostview) { // Expand received image. component2 = }> ; } else if (this.state.videoPostview) { // Play received video. component2 = }> ; } else if (this.state.docPreview) { // Preview attachment before sending. if (TheCard.isFileSupported(this.state.docPreview.type, this.state.docPreview.name) || this.state.docPreview.type == TheCard.contentType) { component2 = }> ; } else { component2 = }> ; } } else { const topic = this.props.tinode.getTopic(this.state.topic); const isChannel = topic.isChannelType() || topic.chan; const groupTopic = topic.isGroupType() && !isChannel; const icon_badges = []; if (topic.trusted) { if (topic.trusted.verified) { icon_badges.push({icon: 'verified', color: 'badge-inv'}); } if (topic.trusted.staff) { icon_badges.push({icon: 'staff', color: 'badge-inv'}); } if (topic.trusted.danger) { icon_badges.push({icon: 'dangerous', color: 'badge-inv'}); } } const pinnedMessages = []; this.state.pins.forEach(seq => pinnedMessages.push(topic.latestMsgVersion(seq) || topic.findMessage(seq))); const messageNodes = []; let previousFrom = null; let prevDate = null; let chatBoxClass = null; topic.messages((msg, prev, next, i) => { let nextFrom = next ? (next.from || 'chan') : null; let sequence = 'single'; let thisFrom = msg.from || 'chan'; if (thisFrom == previousFrom) { if (thisFrom == nextFrom) { sequence = 'middle'; } else { sequence = 'last'; } } else if (thisFrom == nextFrom) { sequence = 'first'; } previousFrom = thisFrom; const isReply = !(thisFrom == this.props.myUserId); const deliveryStatus = topic.msgStatus(msg, true); let userFrom = thisFrom, userName, userAvatar; const user = topic.userDesc(thisFrom); if (user && user.public) { userName = user.public.fn; userAvatar = makeImageUrl(user.public.photo); } chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box'; // Ref for this chat message. const ref = this.getOrCreateMessageRef(msg.seq); let replyToSeq = msg.head ? parseInt(msg.head.reply) : null; if (!replyToSeq || isNaN(replyToSeq)) { replyToSeq = null; } if (msg.hi) { // Deleted message. messageNodes.push( ); } else { const thisDate = new Date(msg.ts); // This message was sent on a different date than the previous. if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) { messageNodes.push( ); prevDate = thisDate; } messageNodes.push( ); } }); let lastSeen = null; if (isChannel) { if (topic.subcnt) { lastSeen = formatMessage(messages.subscriber_count, {count: topic.subcnt}); } } else { const cont = this.props.tinode.getMeTopic().getContact(this.state.topic); if (cont) { if (Tinode.isP2PTopicName(cont.topic)) { if (cont.online) { lastSeen = formatMessage(messages.online_now); } else if (cont.seen) { lastSeen = formatMessage(messages.last_seen, {timestamp: shortDateFormat(cont.seen.when, this.props.intl.locale)}); // TODO: also handle user agent in c.seen.ua } } else if (Tinode.isGroupTopicName(cont.topic)) { if (topic.subcnt) { lastSeen = formatMessage(messages.member_count, {count: topic.subcnt}); } } } } const avatar = this.state.avatar || true; const online = this.state.deleted || topic.isSelfType() ? null : this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline'; const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : ''); const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : ''; // sendMessage() unconditionally sends whatever content it's given with head={replace:...} // while this.state.reply.editing is set (see sendMessage above) -- it has no way to tell // "the user is still editing the original text" apart from "the user just picked a photo/ // file/card/favorite from the attachment panel while that edit was still open". Gating the // attach entry points closed here (same mechanism already used for forwardMessage below) is // simpler and safer than trying to teach sendMessage to distinguish those cases after the fact. const editingMessage = !!(this.state.reply && this.state.reply.editing); let messagesComponent = ( <> {this.state.groupAnnouncement ? campaign {this.state.groupAnnouncement.content} chevron_right : null} {this.state.pendingJoinRequests > 0 ? person_add_alt 新邀请进群申请({this.state.pendingJoinRequests}) chevron_right : null}
    {messageNodes}
{!this.state.isReader ?
{this.state.readingBlocked ?
: null }
: null }
check {this.state.toast}
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
block .
: null} {!this.state.unconfirmed && this.state.relationshipContext && this.state.relationshipContext.relationship == 'pending_inbound' ?
以上是打招呼的消息
: null} {!this.state.unconfirmed && this.state.relationshipContext && this.state.relationshipContext.relationship == 'pending_outbound' ?
好友申请正在等待处理
: null} {!this.state.unconfirmed && this.state.relationshipContext && this.state.relationshipContext.relationship == 'none' ?
你们还不是好友,发送消息前需先验证 发送朋友验证
: null} {this.props.selectMode ? : this.state.unconfirmed ? : } ); component2 = ( <>
{this.props.displayMobile ? {e.preventDefault(); this.leave(this.state.topic); this.props.onHideMessagesView();}}> arrow_back : null}
{!isChannel ? : null}
{ this.state.title || }
{lastSeen}
{!this.props.displayMobile && this.state.pins.length > 0 ? this.setState({selectedPin: index})} onSelected={this.handleQuoteClick} onCancel={this.handleUnpinMessage} /> : null} {groupTopic ? : null }
{this.props.displayMobile ? <> {this.state.pins.length > 0 ? this.setState({selectedPin: index})} onSelected={this.handleQuoteClick} onCancel={this.handleUnpinMessage} /> : null } : null} {messagesComponent} {this.state.dragging && this.isDragEnabled() ?
{formatMessage(messages.drag_file)}
: null} ); } component =
{component2}{overlay}{riskDialog}
} return component; } }; export default injectIntl(MessagesView);