第五轮深度测试:管理员撤回、所有人@提醒、失效附件UI,并核对现存后端问题清单
- messages-view.jsx: 拖拽发送/回复菜单在编辑态下的旁路修复;群主/管理员可撤回他人消息 (对齐后端 authorizeIMMessageMutation 的 allowAdmin);新增"所有人"@提醒(head.mention_all)。 - chat-message.jsx: system_action 头类型消息按 system_notice 同款渲染(此前显示"内容无效")。 - content-library-view.jsx: 语音/链接/图文收藏与聊天记录卡片补上 removed(已失效)态UI, 之前只有 FileCard 处理了这个状态。 - call-panel.jsx: handleVideoOfferMsg(5步.then链,用reject sentinel跳过后续步骤)、 handleToggleSpeakerClick 补组件卸载守卫,修完此前遗漏的两处。 - group-management-view.jsx: 治理开关(Governance.setField)和群主转让(OwnerSelect) 补上乐观锁 version 参数(此前从不传,服务端 version<=0 时会跳过冲突检测);群文件搜索框 文案承诺"搜索文件名或者上传者"但服务端q参数只匹配文件名,改为拉取全量列表本地按文件名+ 已解析的上传者昵称一起过滤;群公告详情页补上从未被调用过的已读上报(readGroupAnnouncement)。 以上均用真实账号在浏览器/直接请求验证:管理员撤回、system_action渲染、mention_all、 文件按上传者搜索、公告已读上报(200)、治理开关stale-version探测确认409冲突检测生效。 7个并行测试agent对全部11章做了第二轮回归+现存后端问题清单逐条复核,结论待整理进 docs/IM-后端问题清单*.md(全部现存条目复核后均建议保留,多条证据强度从推断升级为已验证)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4a30cfa6b3
commit
d42591284f
@@ -126,10 +126,17 @@ function FileCard({name, size, mime, sender, removed, onOpen}) {
|
||||
// Real playback via the same widgets/audio-player.jsx used for live voice messages, rather than the
|
||||
// mock's plain state-toggle with no actual <audio> element -- resolves its own blob URL like Avatar
|
||||
// does, so callers don't need their own per-item URL-map bookkeeping just for voice favorites.
|
||||
function VoiceCard({att, head}) {
|
||||
// `removed` (source message deleted/recalled, im_http.go's messageAvailability) skips resolving a
|
||||
// blob URL for a file that's gone -- without this, a removed voice favorite just sat on
|
||||
// `hourglass_empty` forever (resolveAttachmentUrl's fetch fails silently), indistinguishable from
|
||||
// "still loading". Same FileCard-style treatment (an <em> label, no playback) as the file case.
|
||||
function VoiceCard({att, head, removed}) {
|
||||
const [src, setSrc] = React.useState(null);
|
||||
React.useEffect(() => { resolveAttachmentUrl(att).then(setSrc); }, [att]);
|
||||
React.useEffect(() => { if (!removed) resolveAttachmentUrl(att).then(setSrc); }, [att, removed]);
|
||||
const duration = (att && att.duration) || (head && head.duration) || 0;
|
||||
if (removed) {
|
||||
return <div className="library-voice-card removed"><Icon>block</Icon><em>语音已失效</em></div>;
|
||||
}
|
||||
return <div className="library-voice-card">
|
||||
{src ? <AudioPlayer short src={src} duration={duration} preview={att && att.preview} /> : <Icon>hourglass_empty</Icon>}
|
||||
</div>;
|
||||
@@ -139,11 +146,17 @@ function VoiceCard({att, head}) {
|
||||
// copy the raw message content -- no title/thumbnail/summary field is ever populated), matching what
|
||||
// every one of the reference screenshots for both 我的收藏-链接 and 查找聊天记录-链接 actually show: a
|
||||
// plain URL string, not a card. Rendering a rich card here would be inventing data that doesn't exist.
|
||||
function LinkText({url, onOpen}) {
|
||||
function LinkText({url, onOpen, removed}) {
|
||||
if (removed) {
|
||||
return <div className="library-link-text removed"><Icon>link_off</Icon><em>链接已失效</em></div>;
|
||||
}
|
||||
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
|
||||
}
|
||||
|
||||
function MediaThumb({url, video, duration, onOpen}) {
|
||||
function MediaThumb({url, video, duration, onOpen, removed}) {
|
||||
if (removed) {
|
||||
return <div className="library-media-thumb removed"><Icon>{video ? 'videocam_off' : 'broken_image'}</Icon><em>{video ? '视频已失效' : '图片已失效'}</em></div>;
|
||||
}
|
||||
return <button type="button" className="library-media-thumb" onClick={onOpen}>
|
||||
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
|
||||
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
|
||||
@@ -157,14 +170,17 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
|
||||
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||
<div className="library-favorite-content">
|
||||
{item.type === 'text' ? <p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p> : null}
|
||||
{/* removed 一直没传,需求 7.3「失效文件需展示明确状态而非直接报错」在收藏列表里就没生效——
|
||||
聊天记录页(下面 history-files 那处)本来就传了,只有收藏这条漏了。后端
|
||||
messageAvailability(im_http.go:9113)在源消息被删/被撤回时给 'removed'。 */}
|
||||
{/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard),
|
||||
voice/link/image/video 三类完全没处理——即便后端把"问题13"(availability 恒为 available)
|
||||
修好,这三类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
|
||||
"还在加载中"没法区分的占位。四个类型现在统一都传。后端 messageAvailability(im_http.go)
|
||||
在源消息被删/被撤回时给 'removed'。 */}
|
||||
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime}
|
||||
removed={item.availability === 'removed'} onOpen={onOpen} /> : null}
|
||||
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} /> : null}
|
||||
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)} onOpen={onOpen} /> : null}
|
||||
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} /> : null}
|
||||
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : null}
|
||||
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}
|
||||
onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
|
||||
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
|
||||
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
|
||||
</div>
|
||||
</article>;
|
||||
@@ -560,10 +576,11 @@ function History({view, hashParams, myUserId, onToast}) {
|
||||
})}</section> :
|
||||
view === 'history-links' ? <section className="library-record-links">{list.map(item => <React.Fragment key={item.seq}>
|
||||
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
|
||||
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)} onOpen={openLink} />
|
||||
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)}
|
||||
onOpen={openLink} removed={item.availability === 'removed'} />
|
||||
</React.Fragment>)}</section> :
|
||||
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
|
||||
duration={item.duration} onOpen={() => jump(item.seq)} />)}</section>}
|
||||
duration={item.duration} onOpen={() => jump(item.seq)} removed={item.availability === 'removed'} />)}</section>}
|
||||
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
|
||||
</main>
|
||||
</div>;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
|
||||
updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
|
||||
acceptGroupJoinRequest, rejectGroupJoinRequest, getLatestGroupAnnouncement,
|
||||
updateCurrentGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||||
updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||||
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
|
||||
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
|
||||
getGroupMemberProfile
|
||||
@@ -363,6 +363,16 @@ function Announcement({topic, myRole, fromChat, onToast}) {
|
||||
setData(d); setEditing(false); onToast('群公告已发布');
|
||||
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
|
||||
};
|
||||
// 6.9 要求"记录已读状态",但这个接口(POST .../read)之前完全没有调用点——每次打开公告详情页
|
||||
// (无论从群设置点进来还是从聊天顶部横幅点进来)都视为"已读",用ref去重避免同一条公告在同一次
|
||||
// 页面停留内重复上报。静默失败:已读打点不是这个页面的主要功能,不应该在失败时打断用户。
|
||||
const readReportedFor = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const id = data && data.announcement_id;
|
||||
if (!id || !topic || readReportedFor.current === id) return;
|
||||
readReportedFor.current = id;
|
||||
readGroupAnnouncement(topic, id).catch(() => {});
|
||||
}, [topic, data]);
|
||||
if (!data) return <div className="group-page"><Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
|
||||
return <div className="group-page">
|
||||
<Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined}
|
||||
@@ -640,7 +650,12 @@ function Governance({topic, tinode, onToast}) {
|
||||
getGroupGovernance(topic).then(d => { writeCachedData('group-governance:' + topic, d); setGov(d); }).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
const setField = (key, value) => {
|
||||
updateGroupGovernance(topic, {[key]: value}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||||
// GET .../governance does return a real `version` (confirmed live) -- omitting it here isn't
|
||||
// just "no version sent", the server-side UPDATE for this endpoint skips its optimistic-lock
|
||||
// WHERE clause entirely whenever version<=0, so two admins toggling different switches at
|
||||
// nearly the same time would have the second write silently clobber the first with no conflict
|
||||
// signal. Sending the version this page already has on hand costs nothing and closes that gap.
|
||||
updateGroupGovernance(topic, {[key]: value, version: gov ? gov.version : 0}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||||
.catch(err => onToast(err.message || '设置失败'));
|
||||
};
|
||||
const dissolve = () => {
|
||||
@@ -860,7 +875,13 @@ function OwnerSelect({topic, onToast}) {
|
||||
if (!password) { onToast('请输入密码以确认'); return; }
|
||||
if (busy || !target) return;
|
||||
setBusy(true);
|
||||
transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
|
||||
// transferGroupOwner's `version` param was never supplied here, so the optimistic-lock WHERE
|
||||
// clause on the backend's UPDATE was silently skipped (version<=0) -- this is the single most
|
||||
// consequential owner-transfer call in the app having zero protection against a concurrent
|
||||
// transfer. Fetch the live group version right before the irreversible call rather than trusting
|
||||
// any value this page might have cached earlier in its lifetime.
|
||||
getGroup(topic).then(group => transferGroupOwner(topic, target.user_id, group.version))
|
||||
.then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
|
||||
.catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));
|
||||
};
|
||||
const row = item => <article key={item.user_id} role="button" tabIndex={0} onClick={() => setTarget(item)}>
|
||||
@@ -898,11 +919,8 @@ const FILE_TYPE_META = {
|
||||
};
|
||||
|
||||
function GroupFiles({topic, tinode, onToast}) {
|
||||
// 群文件搜索走服务端 getGroupFiles({query}),同样防抖。
|
||||
const [queryInput, setQueryInput] = React.useState('');
|
||||
const query = useDebouncedValue(queryInput, 300);
|
||||
// Only the unfiltered (query === '') result is cached -- same rule GroupList already follows, so a
|
||||
// plain revisit never shows a stale search result under a fresh empty query.
|
||||
const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [uploading, setUploading] = React.useState(null);
|
||||
@@ -911,14 +929,19 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
// 显示在"来自"位置。跟 Muted 页一样,拉一次成员表交叉引用出可读的名字。
|
||||
const [uploaderNames, setUploaderNames] = React.useState({});
|
||||
const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};
|
||||
// 搜索框文案承诺"搜索文件名或者上传者",但服务端 ListFiles 的 `q` 只匹配文件名(上传者昵称
|
||||
// 这类展示用的名字根本不在服务端索引里,是前端自己拿 getGroupMembers 交叉查出来的)——如果把
|
||||
// query 传给服务端,按上传者名字搜索永远是静默的空结果。改成服务端只拿全量列表(本来文件数量
|
||||
// 就不大,也没有任何"加载更多"分页UI),文件名/上传者两个维度都在本地用已有的 uploaderNames
|
||||
// 表一起过滤,让搜索框真正兑现自己的文案。
|
||||
const load = React.useCallback(() => {
|
||||
if (!topic) return;
|
||||
getGroupFiles(topic, {query}).then(data => {
|
||||
getGroupFiles(topic, {}).then(data => {
|
||||
const items = data.items || [];
|
||||
if (!query) writeCachedData('group-files:' + topic, items);
|
||||
writeCachedData('group-files:' + topic, items);
|
||||
setFiles(items);
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic, query]);
|
||||
}, [topic]);
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
@@ -928,6 +951,13 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
setUploaderNames(map);
|
||||
}).catch(() => {});
|
||||
}, [topic]);
|
||||
const visibleFiles = React.useMemo(() => {
|
||||
const all = files || [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter(f => (f.file_name || '').toLowerCase().includes(q) ||
|
||||
(uploaderNames[f.uploader_id] || '').toLowerCase().includes(q));
|
||||
}, [files, query, uploaderNames]);
|
||||
const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);
|
||||
const pick = kind => { setMenuOpen(false); inputRefs[kind].current && inputRefs[kind].current.click(); };
|
||||
const onFile = e => {
|
||||
@@ -963,7 +993,8 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
<div><strong>{uploading.name}</strong><small>上传中 {uploading.progress}%</small><progress value={uploading.progress} max="100" /></div>
|
||||
</article> : null}
|
||||
{files === null ? <p className="group-inline-empty">{loadError ? '文件列表加载失败' : '加载中…'}</p> :
|
||||
files.map(file => {
|
||||
visibleFiles.length === 0 && query.trim() ? <p className="group-inline-empty">未找到匹配的文件</p> :
|
||||
visibleFiles.map(file => {
|
||||
const [icon, color] = FILE_TYPE_META[file.file_type] || FILE_TYPE_META.file;
|
||||
return <article key={file.group_file_id}>
|
||||
<span className="group-file-type" style={{backgroundColor: color}}><Icon>{icon}</Icon></span>
|
||||
|
||||
@@ -140,6 +140,11 @@ function isPeerRestricted(acs) {
|
||||
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,
|
||||
@@ -158,6 +163,7 @@ class MessagesView extends React.Component {
|
||||
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);
|
||||
@@ -370,6 +376,7 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
this.mentionedUsers = [];
|
||||
this.mentionAllPending = false;
|
||||
this.loadRelationshipContext(topic);
|
||||
this.loadGroupContext(topic);
|
||||
this.loadDraft(topic);
|
||||
@@ -1076,9 +1083,15 @@ class MessagesView extends React.Component {
|
||||
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.
|
||||
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
|
||||
if (params.userFrom == this.props.myUserId && params.seq > 0 && recallWindow > 0 && params.timestamp &&
|
||||
params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
|
||||
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
|
||||
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && recallWindow > 0 &&
|
||||
params.timestamp && params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
|
||||
menuItems.push('menu_item_recall');
|
||||
}
|
||||
// Self -> (1) always hard-delete with [Delete] title.
|
||||
@@ -1182,10 +1195,31 @@ class MessagesView extends React.Component {
|
||||
if (!topic || !topic.isGroupType()) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return getGroupMembers(this.state.topic, {mentionable: true, query}).then(data => data.items || []);
|
||||
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});
|
||||
}
|
||||
@@ -1445,6 +1479,16 @@ class MessagesView extends React.Component {
|
||||
// (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};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const DIALING_SOUND = new Audio('audio/dialing.m4a');
|
||||
|
||||
const VIDEO_MUTED_EVENT = 'video:muted';
|
||||
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
|
||||
// See handleVideoOfferMsg's use of this below.
|
||||
const UNMOUNTED_DURING_OFFER = Symbol('unmounted-during-offer');
|
||||
|
||||
function formatElapsedTime(totalSeconds) {
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
@@ -571,6 +573,19 @@ class CallPanel extends React.PureComponent {
|
||||
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
|
||||
})
|
||||
.then(stream => {
|
||||
// Same permission-prompt-outlives-the-call race as start()/unmuteVideo()/
|
||||
// handleSwitchCameraClick() -- this is the callee-side counterpart (answering an offer),
|
||||
// a separate getUserMedia call this round's fix missed: start() takes an early-return
|
||||
// branch for an already-in-progress call and never reaches its own getUserMedia here.
|
||||
// A bare `return` wouldn't be enough on its own to bail out of the rest of *this* chain
|
||||
// (createAnswer/setLocalDescription/onSendAnswer below still run on the next tick with
|
||||
// `undefined`) -- rejecting with the sentinel skips straight past all of those (none of
|
||||
// them pass an onRejected handler to .then()) to the .catch() at the end, which checks for
|
||||
// it explicitly so this cancellation isn't reported as a real getUserMedia failure.
|
||||
if (this._unmounted) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return Promise.reject(UNMOUNTED_DURING_OFFER);
|
||||
}
|
||||
let dummyVideo;
|
||||
if (!this.localStreamConstraints.video) {
|
||||
// Starting an audio-only call. Create an empty video track so
|
||||
@@ -602,7 +617,7 @@ class CallPanel extends React.PureComponent {
|
||||
this.props.onSendAnswer(this.props.topic, this.props.seq, pc.localDescription.toJSON());
|
||||
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
|
||||
})
|
||||
.catch(this.handleGetUserMediaError);
|
||||
.catch(err => { if (err !== UNMOUNTED_DURING_OFFER) { this.handleGetUserMediaError(err); } });
|
||||
}
|
||||
|
||||
// Call disconnected by remote.
|
||||
@@ -781,9 +796,15 @@ class CallPanel extends React.PureComponent {
|
||||
const match = nextOn ?
|
||||
outputs.find(d => /speaker/i.test(d.label)) :
|
||||
outputs.find(d => /earpiece|receiver/i.test(d.label));
|
||||
// Same unmount race as the gUM-based methods -- enumerateDevices can still be pending
|
||||
// after the call ends. Currently silent either way (the .catch below only logs), so this
|
||||
// just avoids a pointless TypeError in the console rather than fixing a visible symptom.
|
||||
if (this._unmounted || !this.remoteRef.current) {
|
||||
return null;
|
||||
}
|
||||
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
|
||||
})
|
||||
.then(_ => this.setState({speakerOn: nextOn}))
|
||||
.then(_ => { if (!this._unmounted) { this.setState({speakerOn: nextOn}); } })
|
||||
.catch(err => console.error('Failed to switch audio output device', err));
|
||||
}
|
||||
|
||||
|
||||
@@ -271,13 +271,16 @@ class BaseChatMessage extends React.PureComponent {
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
|
||||
let content = this.props.content;
|
||||
// Server-generated conversation notices (currently just `friend_added`, sent once when two
|
||||
// users become friends -- see server's publishIMConversationSystemNotice) carry a custom JSON
|
||||
// shape ({template, text, actors, ...}), not Drafty and not a plain string, so without this it
|
||||
// falls through to the generic "invalid content" placeholder below. Reduce it 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' &&
|
||||
// Server-generated conversation notices (`system_notice`, e.g. `friend_added` sent once when two
|
||||
// users become friends -- see server's publishIMConversationSystemNotice) and group system-action
|
||||
// events (`system_action`, e.g. group_member_joined/join_request_approved/governance changes --
|
||||
// server/im_http.go's group event publishers) both carry a custom JSON shape
|
||||
// ({template, text, actors/actions, ...}), not Drafty and not a plain string, so without this
|
||||
// they fall through to the generic "invalid content" placeholder below -- a real, human-readable
|
||||
// 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') &&
|
||||
content && typeof content == 'object' && typeof content.text == 'string') {
|
||||
content = content.text;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user