修复群邀请消息卡片丢弃"立即加入"按钮,被邀请人无法接受邀请

根因:POST /groups/{topic}/invitations 邀请成功后,后端会往邀请人与被邀请人的
单聊会话里发一条 x-im-type=system_action、template=group_invitation_created 的
消息,content.actions 里带着真实可用的 {type:"group_invitation_accept",
label:"立即加入", target_id:<invitation_id>}(对照参考图 6.3 加入群聊.png)。但
chat-message.jsx 原来对所有 system_action/system_notice 消息一律只取
content.text 渲染成纯文本,这个 action 数据被整体丢弃,消息只显示"邀请你加入群聊。"
没有任何按钮——im-api.js 里封装好的 acceptGroupInvitation() 因此从来没有调用点。

怎么修:chat-message.jsx 在原有的文本兜底之前,单独识别 content.actions 里
type=="group_invitation_accept" 的这一种模板,渲染一张带按钮的小卡片(其余
system_action/system_notice 模板的纯文本渲染保持不变,不受影响);
messages-view.jsx 新增 handleAcceptGroupInvitation,调用 acceptGroupInvitation()
并用已有的 showToast 反馈结果,逐层传给 ChatMessage 的新 onAcceptGroupInvitation
prop。按钮点击后按 busy/joined/pending_approval/error 四态展示,避免重复提交。

验证:用两个真实账号(A 建群,直接对 B 发起 POST /invitations)复现,重新构建后
B 的会话里正确出现"邀请你加入群聊。"+"立即加入"按钮;点击后正确调用
POST /group-invitations/{id}/accept 并把返回的失败原因("服务器繁忙,请稍后重试")
展示出来——这一步暴露了一个独立的后端 500 bug(accept 接口本身的实现有问题,
已记录在测试报告里,不在本次前端修复范围内),前端的请求发起、按钮状态机和错误展示
均已确认按预期工作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 13:26:14 +08:00
co-authored by Claude Sonnet 5
parent a6014b84ee
commit cdca509183
10 changed files with 240 additions and 22 deletions
+55 -3
View File
@@ -47,7 +47,10 @@ class BaseChatMessage extends React.PureComponent {
this.state = {
progress: 0,
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq),
invitationBusy: false,
invitationResult: null,
invitationError: null
};
if (props.uploader) {
@@ -62,6 +65,7 @@ class BaseChatMessage extends React.PureComponent {
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDraftyClick = this.handleDraftyClick.bind(this);
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
this.handleAcceptGroupInvitation = this.handleAcceptGroupInvitation.bind(this);
this.formatterContext = {
formatMessage: props.intl.formatMessage.bind(props.intl),
@@ -232,6 +236,39 @@ class BaseChatMessage extends React.PureComponent {
}
}
// See the long comment above messages-view.jsx's handleAcceptGroupInvitation for the backstory --
// this just drives this one message bubble's own busy/done/error display around that call.
handleAcceptGroupInvitation(invitationId) {
if (this.state.invitationBusy || this.state.invitationResult || !this.props.onAcceptGroupInvitation) {
return;
}
this.setState({invitationBusy: true, invitationError: null});
this.props.onAcceptGroupInvitation(invitationId).then(result => {
this.setState({invitationBusy: false, invitationResult: (result && result.status) || 'joined'});
}).catch(err => {
this.setState({invitationBusy: false, invitationError: (err && err.message) || '操作失败,请重试'});
});
}
// docs/images/加入群聊.png: a card with the inviter's notice text and a single "立即加入" button.
// Only rendered for the one system_action template that actually carries an actionable
// `content.actions` entry -- every other system_action/system_notice message keeps falling through
// to the plain-text path right below, unchanged.
renderGroupInvitationCard(text, action) {
const {invitationBusy, invitationResult, invitationError} = this.state;
return <div className="message-group-invitation">
<div>{text}</div>
{invitationResult == 'joined' ? <em>已加入群聊</em> :
invitationResult == 'pending_approval' ? <em>已提交申请,等待管理员通过</em> :
invitationResult ? <em>{invitationResult}</em> :
<button type="button" disabled={invitationBusy}
onClick={() => this.handleAcceptGroupInvitation(action.target_id)}>
{invitationBusy ? '处理中…' : (action.label || '立即加入')}
</button>}
{invitationError ? <small className="message-group-invitation-error">{invitationError}</small> : null}
</div>;
}
// Reason caption shown under a failed outgoing message in a 1:1 chat, based on the real
// relationship state (see docs/images/个人对话—拉黑状态/已读状态.png and .../个人对话—删除状态.png).
// Returns null for relationships that don't explain a send failure (friend, pending, unknown) so
@@ -281,7 +318,22 @@ class BaseChatMessage extends React.PureComponent {
// message like "有成员通过审批加入了群聊。" was rendering as a bare warning icon. Reduce either to
// its `text` upfront so the existing plain-string path renders it as an ordinary bubble -- these
// are meant to read like a normal message, not a special banner.
if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') &&
//
// One template is the exception: `group_invitation_created` (docs/images/加入群聊.png) is the one
// system_action message that actually carries a real, backend-honored action -- confirmed live
// against server/im_http.go's serveIMGroupInvitations, content.actions[0] is
// {type:"group_invitation_accept", label:"立即加入", target_id:<invitation_id>}, and
// acceptGroupInvitation() already existed in im-api.js with zero call sites because this action
// data was being thrown away right here along with everything else system_action carries. Collapsing
// it to bare text (like every other system_action template correctly does) left the invitee with no
// way to ever accept -- the reference image's whole point is the "立即加入" button.
const systemActions = content && typeof content == 'object' && Array.isArray(content.actions) ? content.actions : null;
const inviteAction = this.props.head && this.props.head['x-im-type'] === 'system_action' && systemActions &&
systemActions.find(a => a && a.type === 'group_invitation_accept');
let invitationCard = null;
if (inviteAction && typeof content.text == 'string') {
invitationCard = this.renderGroupInvitationCard(content.text, inviteAction);
} else if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') &&
content && typeof content == 'object' && typeof content.text == 'string') {
content = content.text;
}
@@ -353,7 +405,7 @@ class BaseChatMessage extends React.PureComponent {
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
<div className="content-meta">
<div className={textSizeClass}>
{bundleCard || content}
{invitationCard || bundleCard || content}
{attachments}
</div>
{this.props.timestamp ?