HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径: parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调 pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash 那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack() 不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方 原本直接读的 window.location.hash(这个类从来没包装过这个读取)。 一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里 的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫 会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理 这种情况,对外的调用方(19个文件)完全无感。 tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate; service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行, 没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用 都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误 资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只 更新了注释。 实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回" 按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/ 个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2820 lines
125 KiB
React
2820 lines
125 KiB
React
// Panel with a chat.
|
||
|
||
import React, { Suspense } from 'react';
|
||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||
|
||
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
|
||
|
||
// webpack.config.js maps the 'tinode-sdk' import to the global `tinode` UMD bundle, which only
|
||
// re-exports {AccessMode, Drafty, TheCard, Tinode} (see umd/tinode.dev.js's own top-level export
|
||
// list) -- LOCAL_SEQID was never one of them, despite tinode-sdk's ES module genuinely exporting
|
||
// it (confirmed directly against umd/tinode.dev.js, which still defines it internally as
|
||
// `const LOCAL_SEQID = 0xFFFFFFF;` and uses it in several of its own topic methods -- it's just
|
||
// never attached to the UMD bundle's exported global object). Importing it by name here silently
|
||
// resolved to `undefined` instead of a build error (an externals mapping can't tell "not on this
|
||
// object" from "doesn't exist"), and every `seq < LOCAL_SEQID` below was therefore
|
||
// `seq < undefined`, which JS always evaluates to false -- confirmed live: "撤回"
|
||
// (menu_item_recall)never appeared in the long-press
|
||
// menu for ANY message, own or admin, however fresh, because the seq-range check that's supposed
|
||
// to just filter out not-yet-confirmed local placeholder messages was unconditionally rejecting
|
||
// every real, server-confirmed message too. Hardcoding the same value the SDK itself uses (rather
|
||
// than trying to change what webpack's externals map exposes, which every other page importing
|
||
// from 'tinode-sdk' already depends on staying exactly as-is) is the narrowest fix.
|
||
const LOCAL_SEQID = 0xFFFFFFF;
|
||
|
||
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 { acceptGroupInvitation, checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap,
|
||
getConversationContext, getFavorites, getFriendRequests, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement,
|
||
getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js';
|
||
import Navigation 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.handleAcceptGroupInvitation = this.handleAcceptGroupInvitation.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,
|
||
pendingRequestMessage: 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,
|
||
pendingRequestMessage: 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 (!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.
|
||
//
|
||
// withData(undefined, undefined, MESSAGES_PAGE) instead of the seemingly-more-natural
|
||
// withLaterData(MESSAGES_PAGE): the latter asks the server for "whatever is newer than
|
||
// topic._maxSeq", which is only correct when _maxSeq reflects a real, contiguous, locally-cached
|
||
// history. But tinode-web.jsx's prefetchLatestMessagePreviews() (conversation list "last
|
||
// message" preview) may have already called topic.subscribe(startMetaQuery().withLaterData(1))
|
||
// for this exact topic the moment the chat list rendered -- often *before* this view ever mounts,
|
||
// e.g. the very first time a brand-new conversation is opened in a fresh session -- which sets
|
||
// _maxSeq to that one prefetched message's seq without caching anything before it. From that
|
||
// point on withLaterData(MESSAGES_PAGE) asks for "later than the one message I already have" and
|
||
// gets back nothing: confirmed live, a brand-new 4-message P2P conversation opened this way
|
||
// rendered only the single latest message, silently missing the rest of the history.
|
||
// withData(undefined, undefined, limit) has no such since-cursor -- it always asks for "the
|
||
// latest `limit` messages" regardless of what the client thinks it already has, which is
|
||
// correct both in this poisoned-prefetch case and in the plain fresh-topic case (there
|
||
// topic._maxSeq is 0, so withLaterData(limit) already degrades to the exact same
|
||
// since:undefined query -- this is a strict fix, not a behavior change for the common path).
|
||
getQuery = getQuery.withData(undefined, undefined, MESSAGES_PAGE);
|
||
if (this.state.isReader) {
|
||
getQuery = getQuery.withLaterDel();
|
||
}
|
||
// And show "loading" spinner.
|
||
this.setState({ fetchingMessages: true });
|
||
}
|
||
|
||
const setQuery = newTopic ? this.props.newTopicParams : undefined;
|
||
const doSubscribe = () => topic.subscribe(getQuery.build(), setQuery)
|
||
.then(ctrl => {
|
||
if (ctrl.code == 303) {
|
||
// Redirect to another topic requested.
|
||
Navigation.navigateTo(Navigation.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);
|
||
});
|
||
|
||
if (topic.isSubscribed()) {
|
||
// Same prefetch race as the comment on the data query above: the topic may already be
|
||
// "attached" server-side from prefetchLatestMessagePreviews' single-message subscribe, before
|
||
// this view ever asked for anything. The SDK's own Topic.subscribe() unconditionally
|
||
// short-circuits to `Promise.resolve(this)` once `_attached` is true (tinode.dev.js)
|
||
// *without even looking at the getParams passed in* -- calling topic.subscribe(getQuery...)
|
||
// directly in that state would silently no-op and this view would never receive the real
|
||
// history/pinned-messages/peer read-receipts it just asked for. leave(false) clears _attached
|
||
// (both client-side via _resetSub() and server-side, since it's a real round trip) without
|
||
// discarding already-cached messages (that only happens for leave(true), i.e. unsubscribe) --
|
||
// once it resolves, the follow-up doSubscribe() is guaranteed to be a genuine, un-shortcut
|
||
// subscribe that gets the server to actually answer getQuery instead of silently dropping it.
|
||
topic.leave(false).catch(_ => { /* best-effort; doSubscribe() below still needs to run */ }).then(doSubscribe);
|
||
return;
|
||
}
|
||
|
||
doSubscribe();
|
||
}
|
||
|
||
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) {
|
||
return;
|
||
}
|
||
this.setState({relationshipContext: data});
|
||
this.loadPendingRequestMessage(topicName, data);
|
||
}).catch(_ => {});
|
||
}
|
||
|
||
// 个人对话框(对方的状态).png / (我的状态).png:pending_inbound/pending_outbound 关系下,聊天
|
||
// 页顶部第一条应该是发起好友申请时留的验证留言原文,以真实消息气泡的样式渲染(对方发的居左、
|
||
// 自己发的居右),下面再跟现有的 #relationship-status-note 横幅。这条留言从来不是一条真实的
|
||
// Tinode 消息(没有走 topic.publish(),只存在 im_friend_requests 表的 message 字段里),所以
|
||
// 不会自然出现在 messageNodes 里——relationshipContextPayload 已经带了 request_id,用它去
|
||
// GET /friend-requests 换真实留言文本;找不到(比如留言本身是空的)就回退成参考图里的默认文案。
|
||
loadPendingRequestMessage(topicName, ctx) {
|
||
const relationship = ctx && ctx.relationship;
|
||
if (relationship != 'pending_inbound' && relationship != 'pending_outbound') {
|
||
this.setState({pendingRequestMessage: null});
|
||
return;
|
||
}
|
||
if (!ctx.request_id) {
|
||
this.setState({pendingRequestMessage: {text: '请求添加你为好友', response: relationship == 'pending_inbound', ts: null}});
|
||
return;
|
||
}
|
||
const direction = relationship == 'pending_inbound' ? 'inbound' : 'outbound';
|
||
getFriendRequests(direction, 'all').then(data => {
|
||
if (this.state.topic != topicName) {
|
||
return;
|
||
}
|
||
const match = (data.items || []).find(item => item.request_id == ctx.request_id);
|
||
this.setState({pendingRequestMessage: {
|
||
text: (match && match.message) || '请求添加你为好友',
|
||
response: relationship == 'pending_inbound',
|
||
ts: match ? new Date(match.created_at) : null
|
||
}});
|
||
}).catch(_ => {
|
||
if (this.state.topic == topicName) {
|
||
this.setState({pendingRequestMessage: {text: '请求添加你为好友', response: relationship == 'pending_inbound', ts: null}});
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 Navigation.currentUrl() 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". handleRouteChange then re-parses path[1] as a topic
|
||
// selector on every route change, fighting with the sidepanel that's trying to open and leaving the
|
||
// whole view blank. Build the target URL 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 = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
|
||
hash = Navigation.addUrlParam(hash, 'from', 'chat');
|
||
Navigation.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 = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
|
||
hash = Navigation.addUrlParam(hash, 'from', 'chat');
|
||
Navigation.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.
|
||
Navigation.navigateTo(Navigation.addUrlParam(
|
||
Navigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
|
||
}
|
||
|
||
// 6.3 加入群聊.png:被邀请人在跟邀请人的会话里收到一张"邀请你加入群聊……立即加入"的卡片。
|
||
// 后端 serveIMGroupInvitations 确实往这个 P2P 会话发了一条 x-im-type=system_action、
|
||
// template=group_invitation_created 的消息,content.actions 里带着真实的
|
||
// {type:"group_invitation_accept", label:"立即加入", target_id:<invitation_id>}——但
|
||
// chat-message.jsx 原来对所有 system_action/system_notice 消息一律只取 content.text 渲染成
|
||
// 纯文本,这个 action 数据被整体丢弃,卡片上不会出现任何按钮,被邀请人无法接受邀请。
|
||
// acceptGroupInvitation() 这个封装早就在 im-api.js 里,只是从未有调用点。
|
||
handleAcceptGroupInvitation(invitationId) {
|
||
return acceptGroupInvitation(invitationId).then(result => {
|
||
if (result && result.status == 'joined') {
|
||
this.showToast('已加入群聊');
|
||
} else if (result && result.status == 'pending_approval') {
|
||
this.showToast('申请已提交,等待管理员通过');
|
||
} else {
|
||
this.showToast('已处理该邀请');
|
||
}
|
||
return result;
|
||
}).catch(err => {
|
||
this.showToast(err.message || '加入群聊失败,请重试');
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
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;
|
||
} 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);
|
||
}
|
||
// Both tinode.dev.js helpers below mutate their first argument in place and return that
|
||
// same reference (Drafty.sanitizeEntities: `return content` after patching content.ent[i]
|
||
// itself; Drafty.append: `first.txt += ...`, `first.fmt.push(...)`, then `return first`) --
|
||
// neither ever clones. Passing `this.state.reply.content` straight through used to hand this
|
||
// live React-state object to both, so every call here permanently welded whatever `msg` this
|
||
// particular attempt carried onto the quoted content the *reply itself* points to. Invisible
|
||
// on an ordinary send since handleCancelReply() clears state.reply right after, discarding the
|
||
// now-mutated copy along with it -- but the URL risk dialog above defers doSend (and this
|
||
// append) until *after* the check resolves, and cancelling it deliberately leaves state.reply
|
||
// alone so the reply survives (see doSend's own comment). Confirmed live: reply to a message,
|
||
// type a flagged link, send, click "取消发送", send the exact same text again, confirm through
|
||
// the dialog this time -- the composed message quoted the reply once but then repeated the
|
||
// typed text twice, because the first (cancelled) attempt had already appended it into
|
||
// state.reply.content, and the second attempt appended onto *that*. A deep clone here keeps
|
||
// both helpers' in-place mutations confined to a throwaway copy every time, however many times
|
||
// sendMessage runs for the same still-open reply.
|
||
const replyContentCopy = JSON.parse(JSON.stringify(this.state.reply.content));
|
||
msg = Drafty.append(Drafty.sanitizeEntities(replyContentCopy), msg);
|
||
}
|
||
} 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 = () => {
|
||
// Was called eagerly above (right after building `msg`/`head` from `this.state.reply`/
|
||
// `this.props.forwardMessage`), synchronously and unconditionally -- including on a message
|
||
// that turns out to need the URL risk dialog below, which only resolves *after* this function
|
||
// already returned. That meant clicking send on a reply/edit containing a link made the
|
||
// "回复"/编辑 preview bar vanish immediately, before the user had even seen the risk prompt;
|
||
// clicking "取消发送" then left the reply/edit permanently abandoned -- the quoted message was
|
||
// gone from the composer with no way back except manually replying again, even though the user
|
||
// only meant to cancel sending the flagged text, not the reply itself. Moving the call here
|
||
// means it only fires at the point a send is actually going to happen: immediately for the
|
||
// common non-URL case (this function runs synchronously right away, same timing as before),
|
||
// or deferred until "继续发送" for a flagged one -- and never at all if the dialog is cancelled,
|
||
// so the reply/edit state and its preview bar correctly survive a cancelled risk prompt.
|
||
this.handleCancelReply();
|
||
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 => {
|
||
// The user may have already switched to a different conversation while checkURL was
|
||
// still in flight. getDerivedStateFromProps only protects against an ALREADY-shown
|
||
// dialog surviving a later topic switch (it nulls riskDialog when nextProps.topic !=
|
||
// prevState.topic) -- it can't protect against this .then() running AFTER that switch,
|
||
// which would otherwise setState({riskDialog:{...}}) fresh right here, popping the risk
|
||
// dialog up while a completely different conversation is now open. doSend() still calls
|
||
// this.props.sendMessage using whatever topic is current AT CONFIRM TIME (see doSend's
|
||
// own comment above), not the one this message was actually typed in, so confirming that
|
||
// dialog would silently deliver this message into the wrong conversation. Confirmed live:
|
||
// typed a flagged URL in topic A, switched to topic B before checkURL resolved, and the
|
||
// resulting dialog rendered while viewing topic B -- confirming it delivered topic A's
|
||
// message into topic B instead. If the topic has moved on, treat this message the same
|
||
// as if it were abandoned -- no dialog, no send -- the same "lost, but not hung forever"
|
||
// outcome already accepted just below for two flagged messages sent in quick succession.
|
||
if (this.state.topic !== topicForDraft) {
|
||
return false;
|
||
}
|
||
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;
|
||
}
|
||
Navigation.navigateTo(Navigation.removeUrlParam(Navigation.currentUrl(), '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;
|
||
}
|
||
|
||
// group_invitation_created is the one system_action template with a real, backend-honored
|
||
// action button (see chat-message.jsx's renderGroupInvitationCard) -- it still needs the
|
||
// full ChatMessage bubble treatment. Every other system_action/system_notice template is
|
||
// purely informational and belongs in the plain centered style below.
|
||
const systemActions = msg.content && typeof msg.content == 'object' && Array.isArray(msg.content.actions) ?
|
||
msg.content.actions : null;
|
||
const isActionableSystemMessage = systemActions && systemActions.some(a => a && a.type == 'group_invitation_accept');
|
||
const isPlainSystemMessage = msg.head && (msg.head['x-im-type'] == 'system_notice' || msg.head['x-im-type'] == 'system_action') &&
|
||
!isActionableSystemMessage && msg.content && typeof msg.content == 'object' && typeof msg.content.text == 'string';
|
||
|
||
if (msg.hi) {
|
||
// Deleted message.
|
||
messageNodes.push(
|
||
<MetaMessage
|
||
deleted={true}
|
||
deletedText={this.props.intl.formatMessage(messages.message_deleted)}
|
||
key={msg.seq} />
|
||
);
|
||
} else if (isPlainSystemMessage) {
|
||
const thisDate = new Date(msg.ts);
|
||
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(
|
||
<MetaMessage
|
||
systemText={msg.content.text}
|
||
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}
|
||
onAcceptGroupInvitation={this.handleAcceptGroupInvitation}
|
||
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}>
|
||
{this.state.pendingRequestMessage ?
|
||
<>
|
||
{this.state.pendingRequestMessage.ts ?
|
||
<MetaMessage date={relativeDateFormat(this.state.pendingRequestMessage.ts)}
|
||
locale={this.props.intl.locale} key="pending-request-date" /> : null}
|
||
<li className={'single ' + (this.state.pendingRequestMessage.response ? 'left' : 'right')}
|
||
key="pending-request-message">
|
||
<div>
|
||
<div className="bubble tip">
|
||
<div className="content-meta">
|
||
<div className="message-content">{this.state.pendingRequestMessage.text}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
</> : null}
|
||
{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> {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);
|