补齐第3-4章composer功能缺口:跨话题草稿/@提醒/表情面板/附件面板/名片/收藏发送/按住说话/语音未读红点
第3-4章复测确认这批原型图要求的功能之前完全没做(真实聊天引擎 messages-view.jsx/
send-message.jsx 从Ch1开始一直是最基础的版本:两个附件图标+文本框+发送/麦克风,
没有@、没有表情面板、没有7宫格附件面板、没有名片/收藏发送、语音是点一下进入录音模式
不是按住说话)。这次一次性把8项都接上,除了新功能外,过程中顺带发现并修了4个真bug。
**跨话题草稿**(im-api.js 新增 getTopicDraft/updateTopicDraft,messages-view.jsx)
话题切换时拉取 GET /topics/{topic}/draft(真实验证过P2P用原生话题名——对方user_id——
可以正常读写,接口内部自己会查Subs.Get校验,不需要额外归一化),输入框内容600ms防抖
PUT回去;真正发送成功后立即清空服务端草稿,不等下一次防抖。真实验证:输入文字→切走
再切回→草稿还在;刷新整个页面(新WS会话)→草稿依然从服务端正确恢复。
**@提醒**(send-message.jsx 新增 mention 相关状态和UI,messages-view.jsx 新增
mentionSearch/mentionedUsers/applyMentionResolution)
只在群聊开放,检测"光标前最近一个未闭合的@"触发面板,调 getGroupMembers(topic,
{mentionable:true, query}) 做实时搜索。协议层面:Tinode原生Drafty.parse本来就会把
纯文本里的"@word"自动识别成MN实体,但data.val存的是匹配到的原始文字,不是uid——
真正的uid只有用户从选择器里点选时才知道,记在 messages-view.jsx 的
this.mentionedUsers,发送前重新parse一遍把匹配上的占位符换成真uid。真实验证:选
"kcg051307"发送后直接查后端存储,MN实体的data.val确认是真实uid
"usrYVeV0yZs8tw",不是原始文字。
**表情面板**(send-message.jsx)
2个tab(😊表情/♡常用),常用表情本地localStorage维护。图片没有拍到第二个tab具体
内容,判断"常用"是这类两tab设计里最常见、也是本项目里唯一有本地存储依据能撑起来的
语义。真实验证:点表情正确插入文本框,用过的表情正确出现在"常用"tab。
**7宫格附件面板**(send-message.jsx,替换原来常驻的照片/文件两个图标)
照片/拍摄/语音通话/名片/文件/收藏/视频通话。语音通话、视频通话复用第8章已有的
handleStartAudioCall/handleStartVideoCall(从 tinode-web.jsx 新抽出 canStartCall()
方法,同时替换掉"..."菜单里原来的内联判断,避免同一个判断逻辑两处维护),仅P2P且
真实通话功能可用时才不置灰——真实验证过群聊里这两项正确显示为禁用状态。
**名片消息**(send-message.jsx openCardPicker/sendCard,复用原生TheCard/
Drafty.appendTheCard)
选好友生成名片,uid必须用TheCard.setTinodeID写进vCard的comm数组(不是随便塞进某个
字段——widgets/the-card-mini.jsx渲染时用TheCard.getFirstTinodeID读这个协议项来判断
"发消息"按钮指向谁)。真实验证:选中好友后发出的名片消息真实带"聊天"按钮,点击可用。
**收藏发送到当前会话**(send-message.jsx openFavPicker/sendFavorite,复用Ch7的
forwardFavorites接口)
真实联调中发现并修复一个新bug:这个接口和其它几个批量接口一样,HTTP层恒定200,
真实成败要看items[].status——原来的写法完全没检查这个字段,等于假装总是成功。已改成
检查失败项并报错。同时真实测试还发现该接口对P2P目标会返回200+items[0].status:
"failed", reason:"PERMISSION_DENIED"(哪怕是真实好友、这个会话本身发消息完全正常),
看起来是后端这个接口自己的写权限判断有问题,不是前端能修的,已记录。
**按住说话+上滑取消录音**(send-message.jsx新增手势处理,audio-recorder.jsx新增
holdMode展示态)
mousedown/touchstart直接挂载AudioRecorder(挂载即开始录音,复用原有机制不动),
移动超过80px判定为"松开取消",松手时通过ref直接调组件内部的handleDone/handleDelete。
过程中发现并修复3个真bug:① injectIntl包裹AudioRecorder默认不支持ref转发
("Function components cannot be given refs"警告,ref静默失效),加
{forwardRef:true}修好;② handleDelete从来没有像handleDone那样判空mediaRecorder,
录音还没真正开始(getUserMedia尚未resolve)就点删除会直接抛TypeError,这是原有代码
一直存在的问题,这次因为按住松手可能在录音刚开始的瞬间发生而被真实触发;③
cleanUp()同理没判空this.stream。补上判空后,"从未真正开始录音就结束"这个场景会
直接当作取消处理并正确调用onDeleted,不会再把UI卡在录音态出不来(真实验证:这个
环境的浏览器面板本身会拦截麦克风访问,用这个"getUserMedia永远拒绝"的真实场景反而
完整测出了正常/取消两条路径在录音真正开始前结束时都不再卡死或报错)。
**语音消息未读红点**(chat-message.jsx/audio-player.jsx/formatters.js)
纯客户端概念,原生Tinode的recv/read只到"消息本身有没有被看到",跟"这条语音有没有
真的被点开听过"是两回事。audio-player.jsx的播放按钮新增可选onPlay回调(不传不影响
现有调用方),formatters.js的AU分支透传给它,chat-message.jsx按话题维护一份
localStorage"已播放seq集合",对收到的(非自己发的)语音消息,没播放过就在气泡右上角
显示红点。真实验证:用原生SDK的_routeData往话题本地缓存注入一条真实结构的语音消息
(浏览器面板拦截麦克风,没有真实语音消息可用,用这个方式验证渲染/播放回调/持久化
三段逻辑),红点正确出现;直接触发播放回调后红点消失且localStorage正确写入对应seq。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
abef778c7f
commit
505a6d7d56
+27
-5
@@ -2838,6 +2838,7 @@ function fullFormatter(style, data, values, key, stack) {
|
||||
attr.duration = data.duration > 0 ? data.duration | 0 : undefined;
|
||||
attr.preview = data.preview;
|
||||
attr.loading = 'lazy';
|
||||
attr.onPlay = this.onVoicePlayed;
|
||||
}
|
||||
el = _widgets_audio_player_jsx__WEBPACK_IMPORTED_MODULE_3__["default"];
|
||||
values = null;
|
||||
@@ -3433,6 +3434,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ getRealnameStatus: function() { return /* binding */ getRealnameStatus; },
|
||||
/* harmony export */ getSystemNotifications: function() { return /* binding */ getSystemNotifications; },
|
||||
/* harmony export */ getTenantContext: function() { return /* binding */ getTenantContext; },
|
||||
/* harmony export */ getTopicDraft: function() { return /* binding */ getTopicDraft; },
|
||||
/* harmony export */ getTopicMedia: function() { return /* binding */ getTopicMedia; },
|
||||
/* harmony export */ getUserProfile: function() { return /* binding */ getUserProfile; },
|
||||
/* harmony export */ getWorkbench: function() { return /* binding */ getWorkbench; },
|
||||
@@ -3489,6 +3491,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ updateMeeting: function() { return /* binding */ updateMeeting; },
|
||||
/* harmony export */ updateMonthlyDraft: function() { return /* binding */ updateMonthlyDraft; },
|
||||
/* harmony export */ updateMyGroupProfile: function() { return /* binding */ updateMyGroupProfile; },
|
||||
/* harmony export */ updateTopicDraft: function() { return /* binding */ updateTopicDraft; },
|
||||
/* harmony export */ uploadLargeFile: function() { return /* binding */ uploadLargeFile; },
|
||||
/* harmony export */ uploadPublicFile: function() { return /* binding */ uploadPublicFile; },
|
||||
/* harmony export */ verifyIndependentPassword: function() { return /* binding */ verifyIndependentPassword; },
|
||||
@@ -4100,6 +4103,12 @@ const getMentionableGroupMembers = (topic, query = '', cursor = '') => imGet('/g
|
||||
query,
|
||||
cursor
|
||||
}));
|
||||
const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
|
||||
const updateTopicDraft = (topic, content, entities, version) => imPut('/topics/' + encodeURIComponent(topic) + '/draft', {
|
||||
content,
|
||||
entities,
|
||||
version
|
||||
});
|
||||
async function fetchFileState(fileId, range = '') {
|
||||
const headers = {
|
||||
'X-MeetingIM-APIKey': _config_js__WEBPACK_IMPORTED_MODULE_0__.API_KEY,
|
||||
@@ -19013,6 +19022,17 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
});
|
||||
}
|
||||
canStartCall(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (!topic || topic._deleted || !tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isP2PTopicName(topicName)) {
|
||||
return false;
|
||||
}
|
||||
const acs = topic.getAccessMode();
|
||||
const writer = !!(acs && acs.isWriter());
|
||||
const cachedBootstrap = this.state.myUserId ? (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_19__.readCachedData)('bootstrap:' + this.state.myUserId) : null;
|
||||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features || cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false;
|
||||
return writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||||
}
|
||||
defaultTopicContextMenu(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (topic._deleted) {
|
||||
@@ -19027,7 +19047,6 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
archived = false,
|
||||
pinned = false,
|
||||
webrtc = false,
|
||||
writer = false,
|
||||
p2p = false,
|
||||
self = false;
|
||||
if (topic) {
|
||||
@@ -19040,12 +19059,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
blocked = !acs.isJoiner();
|
||||
self_blocked = !acs.isJoiner('want');
|
||||
deleter = acs.isDeleter();
|
||||
writer = acs.isWriter();
|
||||
}
|
||||
}
|
||||
const cachedBootstrap = this.state.myUserId ? (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_19__.readCachedData)('bootstrap:' + this.state.myUserId) : null;
|
||||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features || cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false;
|
||||
webrtc = writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||||
webrtc = this.canStartCall(topicName);
|
||||
p2p = tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isP2PTopicName(topicName);
|
||||
self = tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isSelfTopicName(topicName);
|
||||
return [subscribed ? {
|
||||
@@ -19693,6 +19709,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
onCallSendOffer: this.handleCallSendOffer,
|
||||
onCallIceCandidate: this.handleCallIceCandidate,
|
||||
onCallSendAnswer: this.handleCallSendAnswer,
|
||||
canStartCall: this.state.topicSelected ? this.canStartCall(this.state.topicSelected) : false,
|
||||
onStartAudioCall: this.handleStartAudioCall,
|
||||
onStartVideoCall: this.handleStartVideoCall,
|
||||
errorText: this.state.errorText,
|
||||
errorLevel: this.state.errorLevel,
|
||||
errorAction: this.state.errorAction,
|
||||
@@ -22265,6 +22284,9 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.setState({
|
||||
playing: true
|
||||
}, this.visualize);
|
||||
if (this.props.onPlay) {
|
||||
this.props.onPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
handleError(err) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -205,11 +205,16 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
e.preventDefault();
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.stop();
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
this.cleanUp();
|
||||
this.setState({
|
||||
recording: false
|
||||
});
|
||||
if (!this.mediaRecorder && this.props.onDeleted) {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
}
|
||||
handleDone(e) {
|
||||
e.preventDefault();
|
||||
@@ -222,6 +227,8 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
}
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
} else if (this.props.onDeleted) {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
}
|
||||
initCanvas() {
|
||||
@@ -329,13 +336,29 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
if (this.audioInput) {
|
||||
this.audioInput.disconnect();
|
||||
}
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
formatMessage
|
||||
} = this.props.intl;
|
||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||
if (this.props.holdMode) {
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: 'audio audio-hold' + (this.props.cancelling ? ' cancelling' : ''),
|
||||
children: [this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", {
|
||||
ref: this.canvasRef
|
||||
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: "duration",
|
||||
children: this.state.duration
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: "audio-hold-hint",
|
||||
children: this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
}
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: "audio",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("a", {
|
||||
@@ -384,7 +407,9 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
}, void 0, true);
|
||||
}
|
||||
}
|
||||
/* harmony default export */ __webpack_exports__["default"] = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(AudioRecorder));
|
||||
/* harmony default export */ __webpack_exports__["default"] = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(AudioRecorder, {
|
||||
forwardRef: true
|
||||
}));
|
||||
|
||||
/***/ })
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user