Files
im_webapp/src/views/messages-view.jsx
T
Your NameandClaude Sonnet 5 68044fa1bb 修复"查找聊天记录"跳转定位到原消息在全部入口100%失效
componentDidUpdate里的jumpToSeq检查只在这个值发生变化时触发,但
tinode-web.jsx从不会同时渲染MessagesView和第7章侧边栏(!chapterSevenActive
门禁),所以从History的搜索结果/图片宫格/视频宫格跳转,每次都是卸载
第7章视图、挂载一个"首次渲染就已经带着jumpToSeq"的全新MessagesView
实例——componentDidUpdate根本不会跑(它不处理首次挂载)。7.12/7.15/
7.16全部"定位到消息来源"入口100%失效:jumpseq参数永久留在URL里,
会话固定停在最底部。

修复过程中还发现一个真实的竞态:首次挂载意味着componentSetup()刚刚
才发起topic订阅(异步、fire-and-forget),jumpToMessage自己的
topic.getMeta()调用要求订阅已建立,立即调用会抢在订阅完成前——实测
这个抢跑的getMeta()调用真实收到了服务端 {"code":403,"text":"permission
denied"} 拒绝(不是本地兜底,是真实的服务端往返),而真正的订阅请求
当时还在路上。改为轮询已有的isSubscribed()标志(而不是猜一个固定延迟
或改造subscribe()暴露其内部promise),封顶5秒,真正卡住的订阅仍会
落到jumpToMessage自己已有的错误处理上。

第十二轮最小粒度测试 Ch7 发现,已用真实浏览器验证:从工作台等其它
页面跳转过来时,消息正确加载、jumpseq参数正确从URL清除。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:30:53 +08:00

2618 lines
111 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Panel with a chat.
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty, Tinode, TheCard, LOCAL_SEQID } 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;
}
// Synthetic "所有人" entry in the @提醒 picker (mentionSearch below) -- not a real user_id, just a
// marker mentionSelected/sendMessage recognize to set head.mention_all instead of resolving a
// Drafty MN entity to it. Never sent to the server as an actual id.
const MENTION_ALL_UID = '__mention_all__';
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.mentionAllPending = false;
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({}, {});
// Ch.7: jump to a message found via chat-history search/media grid (content-library-view.jsx's
// History) -- the componentDidUpdate check a few lines below only fires on a *change* to
// jumpToSeq while this component stays mounted, but tinode-web.jsx never renders MessagesView
// and a Ch.7 side panel at the same time (`!chapterSevenActive` gates it) -- every jump from
// History's search results/image grid/video grid therefore unmounts the Ch.7 view and mounts a
// *brand new* MessagesView instance with jumpToSeq already set on its very first render, which
// componentDidUpdate never sees (it doesn't run for the initial mount). Confirmed live: the
// seq stayed in the URL indefinitely (jumpToMessage's own `removeUrlParam` call never fired)
// and the conversation opened at the bottom instead of scrolling to the found message --
// 100% reproducible for every Ch.7 "定位到消息来源" entry point, not an intermittent timing issue.
if (this.props.jumpToSeq) {
const seq = parseInt(this.props.jumpToSeq, 10);
const topicObj = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
const attemptJump = () => this.jumpToMessage(seq);
// A first-ever mount also means `componentSetup()` just above only *started* subscribing
// this topic (subscribe() -> topic.subscribe() is async, fire-and-forget from here) --
// jumpToMessage's own topic.getMeta() call requires an already-established subscription,
// and firing immediately races ahead of it: confirmed live, this raced getMeta() call came
// back as a real `{"code":403,"text":"permission denied"}` control message (not a local
// guard, an actual rejected server round trip) while the real subscribe's own request was
// still in flight one id apart. Poll the already-exposed isSubscribed() flag rather than
// guessing a fixed delay or reworking subscribe()'s fire-and-forget call sites to expose
// their promise; capped at 5s so a genuinely stuck subscribe still falls through to
// jumpToMessage's own existing error handling instead of waiting forever.
if (topicObj && !topicObj.isSubscribed()) {
let tries = 0;
const waitForSubscription = () => {
if (topicObj.isSubscribed() || ++tries > 25) {
attemptJump();
} else {
window.setTimeout(waitForSubscription, 200);
}
};
waitForSubscription();
} else {
attemptJump();
}
}
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.mentionAllPending = false;
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 || [];
// Picking "回复" on some other message while already editing one (this.state.reply.editing)
// silently abandons the edit: handlePickReply overwrites this.state.reply wholesale but never
// touches contentToEdit, so the composer keeps showing the message being edited while the
// header/send-icon quietly switch to reply mode -- sending then posts that text as a brand new
// reply to the other message, and the original edit is lost with no confirmation. Simplest fix
// is the same one already used for menu_item_edit's window check: just don't offer the option
// that would cause it, rather than trying to reconcile the two states after the fact.
if (this.state.reply && this.state.reply.editing) {
menuItems = menuItems.filter(item => item !== 'menu_item_reply');
}
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.
// A group admin/owner can also recall *other* members' messages within the same window --
// server/im_http.go's serveIMMessageRecall calls authorizeIMMessageMutation with
// allowAdmin=true, so the backend has always accepted this; the reference screenshot
// (消息页面—长按弹出.png) lists "群撤回" as its own, always-shown menu item alongside "撤回" --
// this was previously never offered at all since the only condition checked was authorship.
//
// `params.seq > 0` alone is not enough to mean "a real, server-persisted message": Tinode
// SDK gives every message that hasn't been confirmed by the server yet (still sending, or
// failed outright) a local placeholder seq of LOCAL_SEQID (0xFFFFFFF = 268435455) -- a large
// *positive* number, so it passed this check. Round 11 live testing reproduced this: sending
// a message into a since-blocked P2P topic left a bubble stuck in "发送失败" with seq
// 268435455, and this menu still offered "撤回" for it -- clicking it fired
// POST /topics/.../messages/268435455/recall, which the server correctly rejects (no such
// message), surfacing a confusing "请求内容有误,请稍后重试" toast for an action that should
// never have been offered. `seq < LOCAL_SEQID` excludes exactly that placeholder range,
// matching the same bound the SDK itself uses internally to distinguish a confirmed seq from
// a queued one.
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && params.seq < LOCAL_SEQID &&
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 => {
const items = data.items || [];
// "所有人" (参考图 @人员—选择提醒人.png 里排在真实成员最前面的合成选项) -- server/topic.go's
// validateAndNormalizeMentions rejects head.mention_all from anyone who isn't admin
// (ErrPermissionDenied), so this is only offered to accounts GET .../members already told us
// can actually use it -- an ineligible account would otherwise see the option, pick it, and
// have the server silently reject the send.
const canMentionAll = !!(data.permissions && data.permissions.can_mention_all);
if (canMentionAll && '所有人'.includes((query || '').trim())) {
return [{user_id: MENTION_ALL_UID, display_name: '所有人'}, ...items];
}
return items;
});
}
mentionSelected(uid, name) {
if (uid === MENTION_ALL_UID) {
// Not a real Drafty mention to resolve -- sendMessage below reads this flag directly to set
// head.mention_all, independent of the this.mentionedUsers/applyMentionResolution machinery
// single-person @提醒 uses. The "@所有人" text itself still gets auto-detected by Drafty.parse
// into an ordinary (uid-less) MN entity and renders highlighted like any other mention --
// that's just decorative here since the real "notify everyone" effect comes from the head flag.
this.mentionAllPending = true;
return;
}
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;
}
// A selected message may still be queued/uploading (no server-confirmed seq yet, same case
// context-menu.jsx's single-message deleteMessages guards with cancelSend before falling
// back to delMessagesList). delMessagesList's normalizeRanges silently drops local-only
// seqs instead of cancelling them, so left unfiltered a still-uploading attachment would
// survive "delete" and get sent normally once its upload finishes.
const remaining = this.props.selectedSeqs.filter(seq => !topic.cancelSend(seq));
if (!remaining.length) {
this.props.onExitSelectMode();
return;
}
topic.delMessagesList(remaining, 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 (this.mentionAllPending) {
// server/topic.go's validateAndNormalizeMentions reads head.mention_all directly -- it's a
// separate signal from the Drafty MN entity the "@所有人" text itself auto-parses into (that
// entity has no real uid and is purely decorative here, same as any other unresolved mention).
// Reset immediately after reading, same lifecycle as this.mentionedUsers just above -- a
// "所有人" pick should only apply to the one message being sent right now, not linger into
// later ones in the same topic.
head = {...(head || {}), mention_all: true};
this.mentionAllPending = false;
}
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.
// Was missing the `g` flag -- .match() without it only ever returns the FIRST url in the message,
// so a second url (e.g. the phishing link in "看这个 https://safe.example.com 但是别信这个
// https://phish.example.com/login?token=abc") never got checked at all as long as the first one
// came back `allow`. Check every distinct url in the message and act on the riskiest verdict.
const urlMatches = originalText && originalText.match(/https?:\/\/\S+/g);
if (urlMatches) {
const RISK_RANK = {block: 2, warn: 1, allow: 0};
const urls = Array.from(new Set(urlMatches));
return Promise.all(urls.map(url => checkURL(url, 'message_compose'))).then(results => {
const result = results.reduce((worst, r) => (RISK_RANK[r.decision] || 0) > (RISK_RANK[worst.decision] || 0) ? r : worst);
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 -- <MetaMessage deleted> 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() {
// Same editing-state guard as the attach-panel props in render() (see the `editingMessage`
// local there) -- drag&drop is a second, independent path into handleFileDrop below, which
// calls handleAttachImageOrVideo/handleAttachFile directly rather than through those props, so
// gating only the props left this route as a full bypass of that guard: dropping a file while
// editing a message (without touching the "+" button at all) still replaced the edited
// message's content via sendMessage's head.replace, with no confirmation.
const editingMessage = !!(this.state.reply && this.state.reply.editing);
return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage &&
!this.state.peerMessagingDisabled && !editingMessage;
}
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 &&
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<CallPanel
topic={this.state.topic}
seq={this.props.callSeq}
callState={this.props.callState}
callAudioOnly={this.props.callAudioOnly}
tinode={this.props.tinode}
title={this.state.title}
avatar={this.state.avatar || true}
minimized={this.state.minimizedCallPanel}
onError={this.props.onError}
onHangup={this.handleCallHangup}
onToggleMinimize={this.handleCallPanelToggle}
onInvite={this.props.onCallInvite}
onSendOffer={this.props.onCallSendOffer}
onIceCandidate={this.props.onCallIceCandidate}
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 />;
} else {
let component2;
if (this.state.imagePreview) {
// Preview image before sending.
component2 = (
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<ImagePreview
content={this.state.imagePreview}
tinode={this.props.tinode}
reply={this.state.reply}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview}
onSendMessage={this.sendImageAttachment} />
</Suspense>
);
} else if (this.state.videoPreview) {
// Preview video.
component2 = <Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<VideoPreview
content={this.state.videoPreview}
tinode={this.props.tinode}
reply={this.state.reply}
onError={this.props.onError}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview}
onSendMessage={this.sendVideoAttachment} />
</Suspense>;
} else if (this.state.imagePostview) {
// Expand received image.
component2 = <Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<ImagePreview
content={this.state.imagePostview}
onClose={this.handleClosePreview} />
</Suspense>;
} else if (this.state.videoPostview) {
// Play received video.
component2 = <Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<VideoPreview
content={this.state.videoPostview}
tinode={this.props.tinode}
onError={this.props.onError}
onClose={this.handleClosePreview} />
</Suspense>;
} 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 = <Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<TheCardPreview
content={this.state.docPreview}
tinode={this.props.tinode}
reply={this.state.reply}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview}
onSendMessage={this.sendFileAttachment} />
</Suspense>;
} else {
component2 = <Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<DocPreview
content={this.state.docPreview}
tinode={this.props.tinode}
reply={this.state.reply}
onCancelReply={this.handleCancelReply}
onClose={this.handleClosePreview}
onSendMessage={this.sendFileAttachment} />
</Suspense>;
}
} 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;
// Set upfront (not just inside the loop below) so an empty (zero-message) conversation still
// gets the base .chat-box class instead of leaving <ul id="scroller"> with no class at all.
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
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);
}
// 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(
<MetaMessage
deleted={true}
deletedText={this.props.intl.formatMessage(messages.message_deleted)}
key={msg.seq} />
);
} 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(
<MetaMessage
date={relativeDateFormat(msg.ts)}
locale={this.props.intl.locale}
key={'date-' + msg.seq} />
);
prevDate = thisDate;
}
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
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}
response={isReply}
seq={msg.seq}
isGroup={groupTopic}
isChan={this.state.channel}
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}
onExpandMedia={this.handleExpandMedia}
onFormResponse={this.handleFormResponse}
onCancelUpload={this.handleCancelUpload}
pickReply={this.handlePickReply}
editMessage={this.handleEditMessage}
onQuoteClick={this.handleQuoteClick}
onAcceptCall={this.props.onAcceptCall}
onError={this.props.onError}
ref={ref}
key={msg.seq} />
);
}
});
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 ?
<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}>
<i className="material-icons">arrow_downward</i>
</button>
<div id="messages-panel" ref={this.handleScrollReference}>
<ul id="scroller" className={chatBoxClass}>
{messageNodes}
</ul>
</div>
{!this.state.isReader ?
<div id="write-only-background">
{this.state.readingBlocked ?
<div id="write-only-note">
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
description="Message shown in topic without the read access" />
</div>
: null }
</div>
: null }
<div className={'toast' + (this.state.toast ? ' show' : '')}>
<i className="material-icons">check</i>&nbsp;{this.state.toast}
</div>
</div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note">
<i className="material-icons secondary">block</i> <FormattedMessage
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
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.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 != null ? this.state.contentToEdit : this.state.draftText}
sendOnEnter={this.props.sendOnEnter}
onKeyPress={this.sendKeyPress}
onRecordingProgress={this.sendKeyPress}
onMessageChange={this.props.forwardMessage ? null : this.handleComposerChange}
onMentionSearch={(this.props.forwardMessage || !groupTopic) ? null : this.mentionSearch}
onMentionSelected={this.mentionSelected}
onSendMessage={this.sendMessage}
onAttachFile={(this.props.forwardMessage || editingMessage) ? null : this.handleAttachFile}
onAttachImage={(this.props.forwardMessage || editingMessage) ? null : this.handleAttachImageOrVideo}
onAttachAudio={(this.props.forwardMessage || editingMessage) ? null : this.sendAudioAttachment}
canStartCall={!this.props.forwardMessage && this.props.canStartCall}
onStartAudioCall={this.props.onStartAudioCall}
onStartVideoCall={this.props.onStartVideoCall}
onError={this.props.onError}
onQuoteClick={this.handleQuoteClick}
onCancelReply={this.handleCancelReply} />}
</>
);
component2 = (
<>
<div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ?
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.leave(this.state.topic); this.props.onHideMessagesView();}}>
<i className="material-icons">arrow_back</i>
</a>
:
null}
<div className="avatar-box">
<LetterTile
authorizeURL={this.props.tinode.authorizeURL}
avatar={avatar}
topic={this.state.topic}
title={this.state.title}
deleted={this.state.deleted} />
{!isChannel ? <span className={online} /> : null}
</div>
<div id="topic-title-group">
<div id="topic-title" className={titleClass}>{
this.state.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>
}<ContactBadges badges={icon_badges} /></div>
<div id="topic-last-seen">{lastSeen}</div>
</div>
<div style={{marginLeft: 'auto'}}/>
{!this.props.displayMobile && this.state.pins.length > 0 ?
<PinnedMessages
tinode={this.props.tinode}
pins={this.state.pins}
messages={pinnedMessages}
selected={this.state.selectedPin}
isAdmin={this.state.isAdmin}
setSelected={index => this.setState({selectedPin: index})}
onSelected={this.handleQuoteClick}
onCancel={this.handleUnpinMessage} />
: null}
{groupTopic ?
<GroupSubs
tinode={this.props.tinode}
subscribers={this.state.onlineSubs} /> :
null
}
<div>
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">more_vert</i>
</a>
</div>
</div>
{this.props.displayMobile ?
<>
{this.state.pins.length > 0 ?
<PinnedMessages
tinode={this.props.tinode}
pins={this.state.pins}
messages={pinnedMessages}
selected={this.state.selectedPin}
isAdmin={this.state.isAdmin}
setSelected={index => this.setState({selectedPin: index})}
onSelected={this.handleQuoteClick}
onCancel={this.handleUnpinMessage} />
: null
}
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} />
</>
: null}
<LoadSpinner show={this.state.fetchingMessages} />
{messagesComponent}
{this.state.dragging && this.isDragEnabled() ?
<div className="drag-n-drop">{formatMessage(messages.drag_file)}</div>
: null}
</>
);
}
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}{riskDialog}</div>
}
return component;
}
};
export default injectIntl(MessagesView);