Compare commits

...
40 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 290fc80f3e 群二维码行的小图标也应跟箭头贴在一起靠右,而不是单独留在文字标签后面
上一次修复把 margin-left:auto 放在了这一行的最后一个图标(箭头)身上,箭头确实到了最右,
但二维码小图标是这一行倒数第二个子元素,没吃到这个 auto margin,还留在原位——变成"二维码
图标贴左、箭头单独贴右"两边分家。auto margin 改放到这一组里最靠前的图标(二维码图标)身上,
箭头作为它后面的下一个兄弟元素自然跟上,两个图标现在整组一起贴右。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:30:43 +08:00
Your NameandClaude Sonnet 5 e66550867c 修复群资料页箭头图标参差不齐
"群头像"/"群二维码"两行没有其它行都有的 <small>(靠 margin-left:auto 把末尾箭头推到最右),
末尾图标紧贴在文字标签后面,导致整页箭头图标一个贴左一个贴右,参差不齐。给这两行末尾图标
直接加 margin-left:auto,跟其它行的箭头对齐到同一条竖线上。

(过程中试过给文字标签加 margin-right:auto 间接推挤,实测反而导致标签本身被推到行中间,
不是预期效果,改为直接在末尾图标自己身上加 margin-left:auto,语义更明确也更可靠。)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:25:42 +08:00
Your NameandClaude Sonnet 5 bd1d6ff0b0 修复群资料页头像被拉伸变形,成员数改为跟列表一致的可点击行
- .group-profile > button > span 漏排除 .group-avatar,把只该用于文字标签列对齐的
  min-width:6rem 也套到了头像 span 上,头像被横向拉伸成80px宽的长方形而不是正方形
- 底部"N位成员"原来是一个孤立的灰底纯文本 <p>,样式和上面的列表格格不入;改成与其它
  行一致的可点击行,点击跳转真实的群成员列表页(复用群聊设置页已验证过的同一个跳转)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:16:55 +08:00
Your NameandClaude Sonnet 5 dfb7ab53a5 修复群组管理页"设置群头像"按钮样式退化成浏览器默认外观
.group-governance 下所有行样式都挂在 "section > button" 选择器上,这一个按钮没包在
<section> 里,完全没吃到列表行样式(全宽/分割线/内边距),退化成一个居左的灰色圆角
小按钮。包一层 <section> 即可,其余4个链接本来就是这样。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:08:57 +08:00
Your NameandClaude Sonnet 5 f2218a822e 修复用户实测反馈的4处问题:品牌名泄露、修改密码页注释泄露、安全提醒页未居中、群相关行间距
- 品牌名:BASE_APP_NAME 从 "TinodeWeb" 改为 "MeetingIM"(关于页/青少年模式文案不再暴露 Tinode 字样);
  logo-view.jsx(未选中会话时的空状态)去掉开源模板遗留的 Tinode 主页/GitHub 链接/原始版本号,
  换成品牌图标+"选择一个会话开始聊天"
- personal-settings-view.jsx:删除 ChangePassword 页面里被误当正文渲染的开发注释(提及 Tinode 协议),
  改回真正的代码注释
- base.css:.risk-info-page(安全提醒整页)补 margin:0 auto——原来复用模态弹窗的 .risk-dialog 样式,
  宽屏下整块内容贴左显示,没有居中
- base.css:.group-member-picker/.group-settings-header/.group-member-section-title 的 strong/small
  漏加入既有的 line-height:1.3 覆盖名单,继承通用 button{line-height:2.2rem},导致群成员列表/群聊设置
  头部的姓名与角色/简介文字行间距异常大、贴底部分割线过近

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:06:18 +08:00
Your NameandClaude Sonnet 5 bede45fd7f 第七轮深度测试:修复7个agent发现的12处前端问题,重点是列表分页截断与呼叫/播放状态机
- 通话状态机(Ch8):canStartCall 补齐通话中重复发起的防护,callShouldStart 补齐放弃/失败路径的清理
- 分页截断(Ch5-6):新增 fetchAllPages 辅助函数,修复通讯录/黑名单/好友申请/群成员/管理员/禁言等
  11 处列表只读首页导致真实成员被遗漏的问题;群角色判断改用不受分页影响的 getGroup().my_role
- 消息与媒体(Ch3-4/Ch7/Ch9):录音超时污染时长、手动重试丢失 head 导致消息被错误分类、批量删除
  未取消上传中附件、语音播放器补齐同时只能播一条、workplace-collaboration-view 冗余重试请求
- 独立密码/青少年模式(Ch10-11):设置密码后按调用来源判断是否应自动开启青少年模式,避免关闭流程
  中途"忘记密码"被误当成开启;注销账号确认文案补充群主后果说明

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:18:25 +08:00
Your NameandClaude Sonnet 5 f42b8536e5 第六轮深度测试:7个并行agent覆盖全部11章,修复21处前端问题
Ch1-2: 用户名注册邀请码错误改为友好文案(原为原始TypeError泄露给用户);
  企业注册页扫描图标补上点击反馈。
Ch3-4: 发名片消息补传typeHint(此前被后端误分类成text,搜索/历史摘要显示成JSON);
  文件卡片按mime大类区分图标颜色(此前所有文件都是同一个灰色通用图标);
  表情面板补上专属退格/发送按钮(浅蓝禁用/深蓝启用两态);订正一处与截图不符的代码注释。
Ch5-6: 群聊设置页"群成员"补上可点击的完整成员列表页(复用MemberPicker字母分组);
  群文件页去掉无依据的硬编码"/10GB"容量上限显示。
Ch7: 收藏点击图片/视频缩略图补上预览;"查找聊天记录-链接"tab补上定位回原消息;
  文件下载页返回入口按来源tab纠正;批量转发成功计数改为按目标会话去重
  (原公式混用"收藏条数"和"目标会话数"两个维度,混选多条收藏转发时可能显示负数);
  收藏/视频tab切换补上URL路由同步。
Ch8: topic.onInfo在通话期间补上事件转发(此前通话中"对方正在输入"和已读回执静默失效);
  主叫等待接听页补上"正在等待对方接受邀请…"文案;语音通话最小化胶囊的还原按钮移出
  拖拽区域(原来嵌在draggable内部,指针捕获可能吞掉点击);修复中等窗宽(641-750px)下
  本地摄像头预览遮挡"切换摄像头"按钮的CSS重叠。
Ch9: 部门树展开时跳过child_count=0的注定空请求;月报字数上限从硬编码1000改为读取
  后端真实的10000;发起会议补上"结束时间需晚于开始时间"的本地校验和更具体的报错;
  日报/月报统计接口补上department_id透传(此前包装函数注释断言后端会忽略,实际已支持)。
Ch10-11: URL风险检测补上g标志(原来一条消息只检查第一个链接,第二个链接哪怕是高危
  也不会被拦截);补上"安全教育"设置入口(原功能完整实现但零入口,需手改hash才能打开);
  修改密码按钮补上busy态反馈;青少年模式PIN页补上"忘记独立密码"入口(后端已支持重置,
  前端未接);"关于我们"补上"联系我们"行。

7个agent同时对全部11章存量后端问题条目逐一复核,结论详见待整理的后端问题清单更新
(Ch1-2发现一条P0级后端问题:验证码固定值"123456",已记录未修复,不在此commit修改
im_be仓库范围内)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:48:28 +08:00
Your NameandClaude Sonnet 5 d42591284f 第五轮深度测试:管理员撤回、所有人@提醒、失效附件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>
2026-09-05 02:07:47 +08:00
Your NameandClaude Sonnet 5 4a30cfa6b3 第四轮深度测试:修复7个agent发现的问题,重点是编辑态附件覆盖的完整旁路和通话面板崩溃防护
再次派7个agent覆盖全部11章,验证第三轮修复+找新问题。本次委托agent专门交叉核对
上一轮每一项修复是否真的生效,多数确认有效,但抓出2处"看似修好、实则有旁路"的情况
和若干新问题。

**编辑消息+拖放文件仍可静默覆盖被编辑消息(上一轮修复的完整旁路,Ch3-4 agent发现)**
上一轮给`onAttachFile`/`onAttachImage`/`onAttachAudio`三个prop加了editingMessage门禁,
但`isDragEnabled()`(messages-view.jsx)没有检查这个状态——拖放文件到聊天窗口走的是
`handleFileDrop`直接调用`handleAttachImageOrVideo`/`handleAttachFile`类方法本身,完全
绕开了被置null的props。编辑态下不点"+"号、直接把文件拖进聊天窗口,依然会静默替换掉
被编辑消息的原文。已给`isDragEnabled()`补上同款判断,这是`handleDrop`和渲染层拖放提示
两处调用的唯一门禁函数,一次性堵住。

同一个agent还发现编辑态下点击"回复"另一条消息会静默放弃当前编辑(`handlePickReply`
覆盖`this.state.reply`但不清`contentToEdit`,发送时值走了`reply`分支而不是`editing`
分支,编辑框里的原文被当成对另一条消息的新回复发出去,原消息未被修改)——已在
`handleShowMessageContextMenu`里比照`menu_item_edit`的年龄过滤模式,编辑态下直接不
提供"回复"这个菜单项。

**实名认证驳回提醒的dismissKey修复上一轮实际没生效(Ch1-2 agent发现,已重新修复)**
上一轮把dismissKey从纯status改成"submission_id或status",但live验证发现
`bootstrap.security.realname`这个数据源根本不包含submission_id字段(后端serveIMBootstrap
的实现只有status/required/allow_skip/remind_after四个硬编码字段)——所以这个修复在
真实环境下永远退化回status,等于没有变化,两次独立驳回依然会互相误压制提醒。这次改为
额外调用专用的`GET /security/realname`接口(该接口的实现确实会返回submission_id),
用它的数据驱动dismissKey和提醒触发条件,bootstrap的字段只保留用于remind_after/
allow_skip这些它独有的字段。已用真实账号验证dismissKey现在正确从这个新数据源生成。

**呼叫面板未卸载防护补全到call-panel.jsx(Ch8 agent发现,同类问题上一轮只修了
audio-recorder.jsx)**
`start()`/`unmuteVideo()`/`handleSwitchCameraClick()`三处的getUserMedia/
enumerateDevices异步链,结尾都会touch `this.localRef.current`/`this.state.localStream`,
但都没检查组件是否已经卸载——权限弹窗未决出结果时通话结束(呼出超时/收到挂断/对方
主动挂断),这些promise resolve后会在死实例上抛TypeError,被各自的catch吞掉后又会展示
一句过期的原始英文报错。三处都补了`this._unmounted`检查,拿到的媒体流/轨道直接停止
而不是继续使用。

**其余修复**:`call-message.jsx`补上和`call-status.jsx`一致的"进行中"状态文案(原来
通话进行中且消息还没有最终时长时显示"0:00"而不是"进行中",已用真实渲染验证过两个
组件行为不一致);`tinode-web.jsx`的`handleCallClose`补上重置`callSeq`(避免跨通话
残留旧值)+ 更新`armCallRingTimeout`注释准确描述它是后端30秒建立超时的二级兜底而非
主要防线(后端`call_establishment_timeout`默认30秒,已有独立机制处理"直接不接"的
常见场景);`REASON_MESSAGES`补充`API_KEY_REQUIRED`/`METHOD_NOT_ALLOWED`(此前记录过
一次真实的部署期API Key配置漂移事故,属于这两个reason的真实触发场景);
`message-center-view.jsx`的`NOTIFICATION_TEXT`补充风险确认"取消"结果的文案(发消息
前风险提示点"取消发送"会产生这个系统通知,此前只收录了"继续");会话列表"置顶"从
只变背景色改为真正排到列表最前(原来只有CSS着色,没有任何排序逻辑,已用真实群聊
置顶验证过排序生效且系统消息/文件助手两个固定入口仍保持在最前);好友聊天设置页
置顶/免打扰开关在初始GET未完成前禁用(避免`version:null`打到后端400);
`content-library-view.jsx`的`DownloadPage`修复带外附件下载使用未做域名归一化的裸
相对路径(生产环境webapp和文件域名跨域时会必现下载失败,已拆出`resolveFileUrl`
辅助函数只做URL拼装不做预先fetch,跟`getFileUrl`的实际下载区分开);
`security-support-view.jsx`的意见反馈分类配置改用`useCachedData`获得重试能力(原来
独立实现的边车缓存逻辑没享受到本轮`use-cached-data.js`的重试修复)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:03:41 +08:00
Your NameandClaude Sonnet 5 93b856aec8 第三轮深度测试:修复7个并行agent发现的前端问题,含REASON_MESSAGES系统性补全
派了Ch1-2/3-4/5-6/7/8/9/10-11共7个agent对全部11章重新深度测试。后端问题已核实追加进
docs/IM-后端问题清单-2026-09-04.md(新增6-9条,发给用户);本次是前端问题的修复。

**REASON_MESSAGES系统性补全(最高优先级,多个agent独立发现同一个缺口)**
im-api.js的REASON_MESSAGES表新增22个条目,最重要的是PERMISSION_DENIED——后端
server/im_http.go里403响应统一用这个reason(30余处调用),但表里原来只有一个从未
被后端实际发送过的死条目FORBIDDEN,导致全站几乎每一个403从工程一开始就在展示原始
英文"permission denied"。一并补上VERSION_CONFLICT/MALFORMED_REQUEST(Ch9 agent实测
命中)、NOT_FOUND、VERIFICATION_RATE_LIMITED(动态文案,同TOO_MANY_ATTEMPTS模式)等。
新增translateReason()导出函数并用于send-message.jsx的sendFavorite——favorites/forward
等几个批量接口恒定200、真实成败包在items[].reason里,imRequest的自动翻译够不到这种
per-item场景,之前直接显示原始reason字符串。tinode-web.jsx同理补了CTRL_ERROR_TEXT_CN
(原生WS {ctrl}协议错误,同样绕开REASON_MESSAGES)并修了原表从未真正匹配过的bug——
CommError会把状态码拼进message变成"authentication failed (401)",原表按裸文本精确
匹配,看着正常纯粹是因为401/403兜底文案凑巧一样,一旦命中"unknown authentication
scheme"这类会被兜底文案误导、"policy violation"这类会直接漏出英文。uploadLargeFile/
createChatUploader/createFileDownloader同样绕开REASON_MESSAGES(走原生{ctrl}协议),
补了UPLOAD_CTRL_TEXT_CN小词典。已用真实账号端到端验证:favorites/forward到P2P目标
触发的PERMISSION_DENIED现在正确显示"没有权限执行此操作";draft接口版本冲突正确显示
"内容已被修改,请刷新后重试"。

**编辑消息时打开附件面板会静默覆盖被编辑消息(Ch3-4 agent发现,数据损坏级别)**
sendMessage对editing状态一律用head.replace,但onAttachFile/onAttachImage/onAttachAudio
原来只在forwardMessage时置空,编辑态没有同款门禁——编辑一条消息不改文字、点"+"选张图片
或名片,会直接把附件内容替换掉被编辑消息的原文,无任何确认。messages-view.jsx新增
editingMessage门禁,和forwardMessage一样把三个attach回调置空(等价于隐藏整个"+"按钮)。
已用真实浏览器验证:编辑态下"+"按钮消失,取消编辑后正常恢复。

**草稿防抖单一共享定时器丢字(Ch1-2、Ch3-4两个agent独立发现)**
handleComposerChange用单个this.draftSaveTimer,话题A打字触发600ms保存后300ms内切到
话题B,会把A的定时器clearTimeout掉,那次输入永久丢失;version也在定时器触发时才重新
读state,此时可能已经在别的话题上,退化成硬编码0——叠加后端draft接口对version=0"无条件
跳过乐观锁"的行为(已记入后端清单新增6),可能静默覆盖别的标签页写入的更新草稿。改为
按话题分开的定时器map,version在调用的当下(保证仍在该话题)同步捕获进闭包。

**呼叫状态机两处真实bug(Ch8 agent此前发现,本次重新核实live代码后确认+修复)**
1. 通话中导航离开messages-view.jsx整个组件树(切到工作台/个人设置,或移动端切回会话
   列表)时,没有任何hangup清理——componentSetup已有的"切换话题时正确挂断"逻辑只在
   componentDidUpdate里,组件真正卸载时不会触发。CallPanel自己的componentWillUnmount
   只做本地清理,不通知对方也不重置tinode-web.jsx顶层callState,导致下次切回通话话题
   时remount出一个callState卡在CALL_STATE_IN_PROGRESS的"僵尸"面板——start()走"我们刚
   接听"分支直接return,从不调getUserMedia,本地媒体永久拿不到。补了对称的
   componentWillUnmount挂断逻辑。
2. this.callTimeoutTimer声明后从未被真正setTimeout赋值——呼出无人接听或accept信令
   (fire-and-forget的{note}包)被丢会永久卡在"呼叫中"。补了armCallRingTimeout(),
   60秒未接通按跟手动取消完全一样的路径挂断(不发明新的通话结果语义),在收到对方
   accept信号的两处分支都正确清timer。

**其余前端修复(按文件)**
- messages-view.jsx: @提醒mentionedUsers只在切话题时清空,同一话题内后续消息里字面
  文本"@张三"会被静默解析成真实提醒(发送后清空);jumpToMessage点开已撤回/删除的
  搜索结果原来只console.error静默失败,补了用户可见的错误提示;批量收藏对
  favorites/batch共享Idempotency-Key导致的静默丢失(已知后端问题)不再照单全收
  "收藏成功",改为发送后重新拉取真实收藏列表核对每条是否真的落地。
- audio-recorder.jsx: getUserMedia权限弹窗未决出结果时组件被卸载,补_unmounted
  标记避免在死实例上继续跑mediaRecorder.start()。
- send-message.jsx: disabled变为true时(对方拉黑/被禁言等实时ACS变化)没有强制结束
  正在进行的按住录音,可能remount出无用户手势的"自动录音";mouseup/touchend覆盖不到
  拖出窗口/切换标签页/权限弹窗抢焦点的释放场景,补了blur/visibilitychange兜底。
- tinode-web.jsx: messages-menu视图和messages-home共用MessageCenterView实例无key,
  从messages-home以非整体刷新方式切到messages-menu时菜单打不开,补key={view}。
- message-center-view.jsx: 实名认证驳回提醒的dismiss key只按用户+状态区分,同一状态
  的两次独立驳回会互相误压制,改按submission_id区分。
- contact-management-view.jsx: Profile/Settings靠getUserProfile刷新拉黑状态,但该
  接口对被拉黑对象直接403,导致"之前会话就已拉黑"的场景完全感知不到——叠加
  getConversationContext兜底(该接口对拉黑目标正确返回200+relationship)。
- group-management-view.jsx: AdminList显示群主/管理员姓名漏了group_nickname兜底;
  JoinRequests从聊天页横幅进入时返回按钮硬编码回group-settings(应回聊天本身),
  从Governance进入时返回group-settings而非group-governance(与AdminList/Muted不一致)。
- workplace-collaboration-view.jsx: OrganizationDirectory/DeptNode的部门树加载失败
  被无条件当成"暂无数据"渲染,补loadError区分+重试链接。
- personal-settings-view.jsx: CacheClear对navigator.storage/caches整个不存在的情况
  只有react出了范围的.catch()覆盖不到的同步抛错,加了特性检测+try/catch;
  YouthModeVerify触发TOO_MANY_ATTEMPTS锁定后确定按钮没有禁用/倒计时,会继续发出
  注定失败的请求。
- security-support-view.jsx: 意见反馈category字段在config未加载完成前可为空但没有
  提交前校验,会打到后端400。
- use-cached-data.js: bootstrap等首次拉取失败且无缓存兜底时永久停在undefined,加了
  一次3秒后重试(仅在完全没有缓存值时触发,已有缓存值维持原"显示旧数据"行为不变)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:29:52 +08:00
Your NameandClaude Opus 5 505a6d7d56 补齐第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>
2026-09-04 21:59:09 +08:00
Your NameandClaude Opus 5 abef778c7f 月报补上团队统计视图,镜像已有的日报团队视图(第9章遗留缺口)
MonthlyReportList 原来只有"我的月报"一种形态,没有 team/managed 入口——日报有的
"团队统计"(应提交/已提交/未提交计数)月报完全没有,路由/组件都不支持。核对
im_be 源码确认后端本身是对称的:TenantMonthlyReportList 的 scope=managed 分支
跟日报字面上是同一段 canUseManagedReports(visibility) 权限校验(真实请求验证:
无权限账号收到 403 PERMISSION_DENIED,不是之前日报那种曾经误报全公司数据的
旧版本行为——那个越权问题后端已经修过了),GET /reports/monthly/statistics
的响应字段(expected_count/submitted_count/missing_count)也和日报完全一致。

唯一确认过的不对称点:月报没有对应的"提醒未提交成员"接口(daily有
POST /reports/daily/reminders,翻遍 im_http.go 月报没有任何同类路由)——团队视图
因此没有画这个按钮,画一个点了必然404的按钮不算镜像,是给用户挖坑。

路由从单一的 monthly-report-list 拆成 monthly-report-mine/monthly-report-team
(对齐 daily-report-mine/daily-report-team 的命名),两处 tinode-web.jsx 的硬编码
白名单、APP_ROUTES、详情/编辑页的返回目标都已同步更新。

联调中发现并顺手修正一个刚写的真bug:team 分支的空列表判断沿用了旧的无条件
"暂无月报"文案,没有区分"真的没数据"和"403加载失败"——用真实账号触发403验证时
第一次跑出来就是这个问题,同一轮改完立刻测出来并修正(跟本次会话更早修的
DailyReportList 同款问题是同一个模式,这次是抄自己代码时漏抄了一半)。

真实验证:我的月报视图不受影响;团队统计视图真实触发403(该账号只是普通员工),
正确显示"月报列表加载失败"而不是误导性的"暂无月报";点击"我的月报"correctly
跳回单人视图。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:27:03 +08:00
Your NameandClaude Opus 5 d01547d3de 重新生成 base.min.css(自9月3日起已落后11次真实CSS提交,生产环境从未见过)
生产环境的 index.html 引用的是 base.min.css,本地/测试用的 index-dev.html 引用的
是未压缩的 base.css——这次全需求复测(含第6-11章一系列真实bug修复+今天新增的
实名提醒弹窗/群主转让确认等样式)改的都是 base.css,没有一次顺手跑
`npm run build:css` 重新生成压缩版。也就是说,这段时间验证过的每一处CSS修复,
如果现在直接照旧部署 index.html 到生产,用户会一处都看不到。

跑 `npx postcss css/base.css > css/base.min.css` 补上这次遗漏,往前追平到
c4189646 之后累计的 11 次 base.css 提交。这个文件本身是纯构建产物(postcss确定性
输出,源头 base.css 早就在开发环境反复验证过),不涉及任何新的手写代码,但下次
真正部署生产环境前必须包含这次重新生成的版本,否则等于白修。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:21:33 +08:00
Your NameandClaude Opus 5 7728afe79f 群主转让加一步密码二次确认,跟账号注销/改密码等其它高风险操作保持一致(第6章复测发现)
serveIMGroupOwnerTransfer 核实过:只认 {target_user_id, version},没有任何
action_token/二次验证机制——不是文档滞后,是这个操作本身就没有服务端强制的二次
确认。但转让群主同样不可逆、后果同样重大("确定后你将立即失去群主身份"),本项目
对同一类场景已有明确先例:personal-settings-view.jsx 的 DeleteAccountConfirm 就是
纯前端加的密码输入门槛,不指望服务端校验它,只是让"手滑点确定"变成需要多打一遍
密码才会发生的事。

OwnerSelect 原来点目标人选后只有一层"确定/取消"的确认表,点确定直接调用真实转让
接口。现在拆成两步:第一步维持原有确认文案不变,第二步新增密码输入,留空点确定
会被拦下(不发请求),填了任意内容才真正调用 transferGroupOwner。

真实验证(真实群+真实候选人):空密码点确定确认没有发出请求;填入密码后
transferGroupOwner 真实调用成功,正确跳转回群组管理页。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:04:26 +08:00
Your NameandClaude Opus 5 542aca3dc2 接线1.10实名认证提醒弹窗到真实消息首页(第1章遗留项,此前一直是孤立demo路由)
auth-onboarding-view.jsx 的 RealnameReminder 组件此前是完全孤立的demo:自己画一套
假会话列表背景,只能靠手动改hash访问,没有任何真实入口会导航过去。核对参考图
"实名认证(安卓).png"确认过的弹窗文案本身是对的,只是从来没有接上真实触发条件。

抽出弹窗内容(不带假背景)做成 RealnameReminderModal,挂到真实 MessageHome 挂载时
按 bootstrap.security.realname 的真实状态判断——这个字段本来就在 MessageHome 已经
在用的同一个 bootstrap 请求里,不需要额外接口调用。

参考图本身画的是不可跳过的单按钮强制态,但真实核对 im_be 源码发现这只是
required:true 那个分支的样子;这个环境的真实配置(也是目前后端唯一会返回的默认值)
是 required:false / allow_skip:true。如果照抄参考图做成完全关不掉的弹层,等于把
"实名认证目前是可选的"这个真实产品配置,实现成挡住所有人使用消息功能的强制墙——
按这两个字段分叉:允许跳过时加一个参考图没画、但后端字段明确允许的关闭按钮;且
遵守后端给的 remind_after(当前值24h),关闭后在这个时长内不重复弹,不是每次进
消息首页都弹一次。

联调中发现关闭按钮初版样式是个跟全局 button 选一样宽的圆角方块(漏了覆盖
min-width/box-shadow/margin 这几个全局 button 规则),已修正为纯图标按钮。

真实验证(账号 asn494180,真实 status:"unverified"):弹窗内容与参考图逐句一致;
点击关闭按钮弹窗消失且 localStorage 正确记录时间戳;刷新页面重新连接后,24小时
窗口内弹窗不会重复出现;未清除记录时保持不弹,符合"提醒而非骚扰"的预期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:40:27 +08:00
Your NameandClaude Opus 5 d21d046443 修复精准查找对拉黑关系显示"添加",跟实际能不能添加对不上(第5章复测发现)
/users/search 返回结果本身带 relationship 字段,服务端 imRelationshipAction() 对
blocked_by_me/blocked_by_peer 都明确映射成 action:"none"(不允许发起添加)——但
前端 actionLabel 只认 friend/pending_outbound/pending_inbound 三种,其余(含两种
拉黑关系)一律落到兜底的"添加"文案。点进去虽然会跳到资料页、由资料页自己的
cannotAddReason 挡住(不会真的误发出申请),但列表这一行本身的文案是误导的。
补上跟 Profile 组件里已有的 cannotAddReason 一致的两条文案(已拉黑/无法添加),
只是精简成适合一行列表项的长度。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:25:44 +08:00
Your NameandClaude Opus 5 7340b5e135 修复消息首页"置顶聊天"读了个不存在的字段,从来没有视觉效果(第2章复测发现)
normalizeConversations 里 pinned 读的是 item.pinned——原生 Tinode 订阅对象上根本
没有这个键,恒为 undefined。第5/6章的置顶开关点了之后,服务端确实存了,但这个
列表拿到的永远是"没置顶",用户会觉得这个开关是假的。

真实存放位置是订阅的原生 private.pinned 字段,但只对群聊成立:群聊置顶走
updateGroupMemberSettings,服务端真的写进 Subs.Private,会随原生 {meta} 同步下来
(真实验证:重连后能看到;同一连接内没有实时 {pres} 通知,所以点亮开关那一刻不会
立即反映在列表上,要等下一次重连,这个次要延迟这次没有顺手解决)。单聊置顶走的
是完全不同的 updateConversationSettings,服务端存进独立的 im_conversation_settings
表,从不写入原生 Subs.Private——不管等多久重连都不会在这里出现,这是真实的后端
架构缺口(没有批量查询会话设置的接口,前端不可能为渲染一屏列表去挨个 topic 发
请求核对置顶状态),已记录、这次没法在前端解决。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:25:23 +08:00
Your NameandClaude Opus 5 4337c45674 修复第9章工作台三处问题:日报列表错误吞没/月报重新编辑未接线/信箱附件超限(复测发现)
1. DailyReportList 的 load() 把请求失败(网络错误、403等)整个 catch 吞掉,只留一个
   空/旧列表,跟"今天真的没有日报"是同一种"暂无日报"空态展示——用户没法分辨到底是
   没数据还是加载失败了。补上 loadError + onToast,参照 DailyReportDetail/
   MonthlyReportList 已有的同款处理方式。

2. reopenMonthlyReport 早就在 im-api.js 里写好但零调用点:MonthlyReportDetail 一直
   没有"重新编辑"入口,已提交的月报只能干看着改不了。核对 im_be 源码发现原函数还多
   传了一个 reason 字段——serveIMMonthlyReportReopen 用 DisallowUnknownFields 只认
   {version},真调用起来会先 400。同时确认这是纯自助操作(SQL 的 UPDATE 条件里
   author_id=调用者自己),不存在"管理员帮别人退回"这回事,去掉多余字段后在
   MonthlyReportDetail 给本人的已提交月报加上这个入口。

   接上之后在真实账号上跑通整条链路时,又炸出一个新竞态:reopen 成功后跳转编辑器,
   编辑器用同一个月份的模块级缓存做"再次进入不清空"的初始渲染,但缓存里存的是
   reopen 之前的旧 version——首次自动保存草稿用这个旧 version 发出去,服务端真实
   返回 409,界面弹出"请检查网络后重新编辑"(其实跟网络毫不相干)。加了个 hydrated
   门槛:只有当组件自己这次挂载真正拿到新鲜数据确认过之后,才允许自动保存的
   effect 生效;没有旧缓存要甄别的场景(绝大多数情况)行为完全不变。修复前后各跑
   了一次完整的"提交→查看详情→重新编辑",前者复现了 409,后者干净无报错。

3. 总经理信箱的附件上传一直没有数量上限——真实 config.attachment_policy.max_files
   (联调确认默认租户是5,不是后端Go默认结构体里那个从未在这个环境出现过的
   max_count)从来没被读取过,界面上能一直点"上传文件"加到任意多个。补上跟
   意见反馈页一致的"数量/上限"展示与到达上限后禁用。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:25:05 +08:00
Your NameandClaude Opus 5 3a78c40389 修复创建群聊名称长度上限与编辑页不一致的问题(第6章复测发现)
创建群聊弹窗的名称输入框 maxLength 是30,但群资料页的编辑器(GroupNameEditor)
是11——两者编辑的是同一个 group.name 字段,建群时能起最多30字的名字,进编辑页
却连原样保存都做不到(打不满30个字符,且无法回退到超过11字的原值)。

核对参考图 设置昵称(1).png:这个字段的编辑器明确画的是"0/11"上限,没有任何一张
图支持30。后端本身不做长度限制(实测 PATCH 17 字符的名称成功),11 纯粹是前端自
己的约束,应该是创建页和编辑页保持一致,而不是让创建页放宽成一个没有依据的数字。

改为创建弹窗也用 maxLength="11",跟编辑页对齐。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:46:25 +08:00
Your NameandClaude Opus 5 211d93a0e4 修复青少年模式独立密码校验报错显示英文原文的问题(第10-11章复测发现)
关闭/开启青少年模式都要先过 POST /settings/independent-password/verify 这一关,
PIN 输错、连续错误触发限流这两种失败,后端 reason 分别是 CODE_INVALID/
TOO_MANY_ATTEMPTS——REASON_MESSAGES 表里没收录这两个 key,直接透传英文原文
message("code invalid"/"too many attempts"),且限流的具体等待时长(details.
retry_after_seconds)完全没有展示给用户。

REASON_MESSAGES 原来只支持字符串值,限流提示需要按 retry_after_seconds 动态拼
文案,所以顺带把 imRequest 的取值逻辑改成支持函数值(传入 payload.details)。

已用真实账号端到端验证:对 https://api.118im.com 连续发送错误 PIN,前3次真实
返回 401 CODE_INVALID,UI 正确显示"独立密码不正确";第4次起后端真实触发限流
返回 429 TOO_MANY_ATTEMPTS(details.retry_after_seconds 从300开始递减),UI
正确显示"连续输错次数过多,请 240 秒后重试",秒数随后端返回值动态变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:46:08 +08:00
Your NameandClaude Sonnet 5 997f8f71d2 修复群成员非好友主页文案错误、添加好友按钮永不出现(第5章复测发现)
navigateToProfile 原来只从群成员列表数据({acs, display_name, group_nickname?, mentionable,
role, user_id})里摘字段做种子缓存,这份数据里根本没有 capabilities/relationship——Profile
组件正是靠这两个字段判断"要不要显示添加好友按钮"和"该显示哪句失败文案"。缺了它们,非好友的
群成员主页会落到 cannotAddReason 的默认分支:不管真实关系是什么,一律显示"好友申请正在等待
验证",添加好友按钮永远不出现。

Profile 组件自己的 getUserProfile(uid) 对非好友是 403(服务端隐私默认),403 时会保留种子
不报错——这个保留种子的兜底本身是对的,问题是种子从一开始就没给对数据。

改用 GET /groups/{topic}/members/{user_id}/profile(getGroupMemberProfile,早就封装好但
零调用点)——群上下文里权限判断更宽松,实测对方哪怕不是好友也能拿到
{capabilities, relationship}。navigateToProfile 现在异步补种:跳转后台发起这个请求,回来后
用真实数据覆盖种子并派发一个事件让已经挂载的 Profile 重新读取。

已用真实账号验证:点击群成员头像后,getGroupMemberProfile 真实请求 200,返回
relationship:"friend"、capabilities.can_add_friend:false;Profile 组件的 React state 和
最终渲染的 DOM 都正确更新成"发消息/音视频通话"(通过 React Fiber 直接读取 state 确认,不只是
看界面文字)。当前两个测试账号互为好友,没能用真正的陌生人群成员验证"添加好友"按钮出现的
那条分支,但接口返回结构已经证实包含判断所需的全部字段,逻辑上会正确生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:56:26 +08:00
Your NameandClaude Sonnet 5 0dcef54fed 修复置顶/免打扰开关未接后端、好友二维码死按钮(第5章复测发现)
置顶聊天/消息免打扰:原来是纯本地 useState,onChange 只切一下布尔值,从不调用任何接口——
GET/PATCH /conversations/{topic}/settings 早就封装好了,只是零调用点。打开设置页永远显示
"关",点了也只是那次会话内生效、离开页面就丢,和会话列表真正读取的置顶/免打扰状态是两套
毫不相关的东西。

实测确认真实字段:{pinned, mute_notifications, version},PATCH 需要带 version 做乐观锁。
接入 getConversationSettings/updateConversationSettings,失败时回滚开关视觉状态。
已用真实账号验证:点击后发出真实 PATCH 请求,带正确的 version。

好友个人主页"二维码"图标:参考图里是独立一行(不是贴在头像卡片里的小图标),点击生成这个
好友的二维码名片,对应接口 createUserQRCode() 早就封装好了,原来这个图标没有任何 onClick,
是个看着能点、点了没反应的死装饰。新增 friend-qr 路由和 FriendQr 组件(复用 QrPage 的结构),
CSS 复用了 .people-settings 那套现成的行样式,不是新造一套。
已用真实账号验证:点击后正确跳转、生成出真实二维码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:43:22 +08:00
Your NameandClaude Sonnet 5 d3d7e11b08 修复会议详情页显示裸user_id的问题(第9章复测发现)
创建/详情/更新/取消/我的回应这5个会议接口的 organizer.display_name/participants[].display_name
实测恒等于裸 user_id,只有列表接口 GET /meetings 会真的解析姓名。之前的注释误以为"响应已带
姓名解析"对所有会议接口都成立(可能是当时只测过列表接口就下了结论),这次复测用真实创建的
会议逐个接口验证,证伪了这个假设。

MeetingDetail 接入已有的 useUserNames(日报/月报详情页已经在用同一套机制),发起人和参会人
姓名解析不出来时不再显示裸 uid。同时更正了 MeetingCard 上那条误导性注释,说清楚它只对列表
接口成立。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:31:55 +08:00
Your NameandClaude Sonnet 5 1d51e53a46 修复通话发起失败静默无反应的问题(第8章复测发现)
sendMessageToTopic 自己的 .catch 会吞掉发送失败——publishMessage 在服务端拒绝时只标记
pub._failed=true 让消息列表画红色感叹号(这是给聊天气泡设计的反馈方式),resolve 成 undefined
而不是 reject。通话邀请这条消息不会被用户看到(用户盯着的是全屏通话面板,不是消息列表),走
这条路径等于什么反馈都没有;而且原代码 `ctrl.code` 直接解引用,undefined.code 会抛
TypeError,变成一个没人 catch 的 rejected promise——handleCallClose() 根本不会被调用,
通话面板会卡在"呼叫中"状态出不来。

这条路径今天有了真实后果:后端新增的全局通话开关(im_client_feature)关闭时,服务端会拒绝
这条 {pub},正好命中这个从未被验证过的失败分支。

修复:判断 ctrl 是否存在、补 .catch,两处都调用 handleError 提示 + handleCallClose 复位。
顺带给通话按钮可见性加了一层预判——读共享的 bootstrap 缓存,features.audio_call/video_call
为 false 时不显示按钮;读不到缓存时按原行为放行(fail-open,只是预判不是安全边界)。

已用真实账号验证:通话菜单项在开关为真时正常显示,无回归。真实失败场景(开关被关闭)无法在
当前浏览器沙盒环境下端到端验证(摄像头/麦克风权限被拦截,此前已确认的环境限制),修复依据是
逐层读 tinode-sdk 源码精确追踪出的调用链,不是猜测。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:14:44 +08:00
Your NameandClaude Sonnet 5 8e703d86eb 修复第2章复测发现的3个问题:好友申请通知按钮/时间格式/新机器码样本
- 好友申请通知的"查看新朋友"按钮永远不出现:判断条件是 action.type==='friend_request',
  但实测 GET /system-notifications 拿到的 friend.request.created 通知,action 里只有
  {user_id},从来没有 type 字段——这个判断条件是当初凭对接文档猜的,不是照真实响应写的。
  改用 event_type.startsWith('friend.request')。
- 系统消息时间格式:默认 toLocaleString('zh-CN') 输出"2026/8/20 18:04:00"(年份/斜杠/秒),
  参考图是"6月28日 10:04"。新增 formatNotificationTime。
- 机器码兜底表补一条:新样本 security.risk.continue 事件的 summary 是 decision 字面量
  "continue",同样会被机器码识别但查表落空、原样显示英文单词。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:06:00 +08:00
Your NameandClaude Sonnet 5 02a565b8e5 修复登录失败显示英文原文的问题(第1章复测发现)
Tinode 原生 WS 登录走 {ctrl} 协议,不经过 im-api.js 的 REASON_MESSAGES 翻译表(那张表只在
REST 请求里生效)。ctrl.text 是服务端 datamodel.go 里固定的英文串("authentication failed"
等),ctrl.params 也没有 REST 层那种稳定的 reason 字段(已实测确认)。原来 doLogin 的 catch
直接把 err.message 原样传给 handleError,密码打错时用户看到的是英文 "authentication failed",
不是参考图要求的"账号或密码错误"。

新增 loginErrorMessage():已知的几个固定英文串映射成中文,未知文案按状态码兜底
(401/403 → 账号或密码错误)。

已用真实账号验证:错误密码显示"账号或密码错误",正确密码登录正常进入工作台。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:21:44 +08:00
Your NameandClaude Opus 5 5bc3fee20b 实现合并转发(第4章 4.20):后端已支持 bundle 模式,补上前后两端
后端把 POST /v1/im/messages/forward 的 mode="bundle" 实现了(此前直接返回 501,前端因此
只做了逐条转发)。这次把前端两侧都补上。

接收侧(新增 widgets/forward-bundle.jsx)
- 服务端生成的这条消息 head 带 x-im-type:"forward_bundle",content 是
  {bundle_id, title, item_count, preview, items, source_refs, comment},不是 Drafty。
  前端全仓搜这个类型零命中,收到会落到 chat-message 的 "invalid content" 兜底
  (⚠ + 灰字)。本端自己发不出 bundle,但别的端发过来就会踩到。
- 渲染成一张"聊天记录"卡片:标题 + 摘要行 + 展开/收起。items[] 里已经带了每条被转发消息的
  完整快照,所以"查看完整记录"是就地展开,不额外发请求。
- 快照里的 from 是不带 usr 前缀的裸 uid(与收藏快照一致),补前缀后查本地会话缓存拿显示名;
  查不到就不显示发送者,宁可少一行也不要把一串 uid 摆在摘要前面。
- 没有正文的媒体消息用 [图片]/[视频]/[语音]/[文件]/[链接] 标签兜底。

发送侧(batch-forward-dialog.jsx)
- 转发模式改为按"用哪种方式选的目标"推导,这是参考图给的规则:
  转发—逐条转发.png 是单选一个目标、占位写 [逐条转发]共2条消息;
  转发—合并转发—多选状态.png 是多选目标、占位写 [合并转发]共16条消息。
  原来 mode 硬编码 'separate'、占位也写死 [逐条转发]。

样式(base.css)
- 新增 .forward-bundle 一组规则,避免又造一批零样式类。

验证
- 用后端 buildForwardBundle 的真实产出结构,注入真实会话的消息缓存,由真正的 ChatMessage
  渲染:卡片正常显示、未落到 invalid content;展开显示全部 5 条、收起回到 3 条;截图确认视觉正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:15:33 +08:00
Your NameandClaude Opus 5 c709abbfa4 修复全页面走查发现的问题(第六批:交互反馈时序)
- contact-management-view: "分享给好友"走剪贴板分支时不给任何反馈,点了界面上毫无动静
  (同页 QrPage.share 是有 toast 的);生成二维码失败也被 .catch(()=>{}) 静默吞掉。
  三条路径都补上反馈。
- group-management-view: 添加管理员/禁言成功后 900ms 就导航,而 CenterToast 生命周期是
  1400ms —— 导航会让整棵子树重挂载把 toast 一起销毁,参考图里那个「设置成功」实际只闪一下
  就被截断。改成 1500ms。

回归验证(本轮全部改动)
- 41 个路由逐个访问:零 JS 错误、零 unhandled rejection、零空白页。
- 22 个页面布局扫描:无横向溢出、无文字零高度、无图标未居中。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:02:15 +08:00
Your NameandClaude Opus 5 08bd8915c5 修复全页面走查发现的问题(第五批:失效文件状态、日报搜索、client_context)
- content-library-view: 收藏列表的 FileCard 一直没传 removed,需求 7.3「失效文件需展示明确
  状态而非直接报错」在收藏页就没生效(聊天记录页本来就传了,只有这条漏了)。后端
  messageAvailability 在源消息被删/撤回时给 'removed'。

- workplace-collaboration-view: 日报"团队统计"的搜索占位符写着"搜索姓名/部门",但过滤用的
  department_name 后端根本不返回(dailyReportListPayload 只有 report_id/author_id/report_date/
  content_short/status/draft_updated_at/read_state/version),拼进来恒为空串,"部门"那半个
  搜索永远搜不到任何东西。改为按真实有的字段搜(姓名+摘要),占位符也改成如实的;
  卡片上同样读了这个不存在字段的角色徽章一并改掉。

- im-api: 企业注册申请 / 入口反馈 / 意见反馈三个接口后端都定义了 client_context 且会入库
  (im_http.go:255/263/367,落库点 :790/:840/:1478),需求与对接文档也都要求带设备版本信息,
  前端一直没发。在 im-api 层统一注入,三处调用点不用各写一遍。navigator.connection 不是所有
  浏览器都有,拿不到就不发 network 这一项。
  已用真实提交验证(返回 fb_G9GtMsAbSHQ6z6Li)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:59:09 +08:00
Your NameandClaude Opus 5 35d3e1cf6b 修复全页面走查发现的问题(第四批:精准查找、隐私锁定、搜索防抖)
精准查找搜不到手机号(第5章 5.5)
- 一直写死 type=exact_account,而该分支只比对 profile.public_account / u.username / u.id
  (tenant_adapter.go:9170-9172),**不匹配 u.phone**。参考图 5.5 里输的恰恰是手机号,
  这个场景必然搜不到。改为按输入形态分流:11 位 1 开头走 type=phone。
- phone 分支服务端不做 E.164 归一化(只有模糊的 account 分支才调 normalizeUserPhone),
  库里存的是 +86 开头,所以裸号码由前端补区号——注册流程本来也是这么拼的。
- 已用真实后端验证请求正确路由为 q=%2B8613800001188&type=phone。

隐私设置忽略 editable(第10章 10.12/10.13)
- 后端 userPrivacyPayload 返回 editable{add_me_methods, profile_visibility,
  last_seen_visibility, read_receipts} 表达"企业强制策略锁死了这一项",前端完全没读。
  被锁的开关照样能点,点了 400 再回滚,用户只看到开关自己弹回去。现在禁用并给出说明文案,
  同时补 .personal-switch:disabled 的视觉反馈(否则看不出为什么点不动)。

搜索防抖
- 新增共享的 useDebouncedValue,用在三处真正发服务端请求的搜索:收藏、会话内检索、
  群列表、群文件。原来 query 直接进 effect 依赖,每敲一个字符发一次请求;群列表更糟,
  reload 对三个分类各发一次,一个字符就是 3 个请求。而且返回顺序没有保证,后发的短前缀
  请求可能后到、把正确结果覆盖掉。
- 实测:群列表输入 4 个字,请求数从 12 降到 3。
- FavoriteShare 那处搜索是本地过滤 chatList、不发请求,没有动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:56:41 +08:00
Your NameandClaude Opus 5 c9b8125013 修复全页面走查发现的问题(第三批:转发、i18n、自聊天显示名)
第4章 批量转发
- 确认弹层里那栏留言是个只读 <div>,需求 4.19 与对接文档要求的"附加留言"根本没法填,
  comment 永远是空串。改成真实输入框(方括号那段是占位文案不是内容),并补 input 的样式。
- 无条数上限:服务端硬卡 sources ≤ 100(im_http.go:3560),超了直接 400 MALFORMED_REQUEST,
  用户只看到一句无从下手的"转发失败"。本地先拦并说清超了多少。
- 无目标数上限:bootstrap 下发 limits.max_forward_targets=9、服务端硬限 20,前端两个都不读。
  读各视图已在写的同一份 bootstrap 缓存,不额外发请求;选择视图也补上错误显示。

i18n(这几条影响真实页面,不只是演示页)
- menu_item_forward "重定向" → "转发",forward_to "重定向消息" → "转发给"。Forward 被当网络
  术语直译了,第4章通篇和参考图用的都是"转发"。
- last_seen_timestamp 中英两侧都丢了 {timestamp} 占位符,离线时头部只显示"最后可见"没有时间。
- peers_messaging_disabled "成员间消息已禁用" → "对方已关闭消息"(原文是 Peer's)。
- search_no_results "搜索返回任何结果" 缺"无";icon_title_send "发信息" → "发送消息"。
- 已跑 build:i18n 同步 src/i18n.min/(那份才是打包进产物的)。

自聊天显示名
- topicDisplayName 不处理 slf,消息首页把"已保存的消息"显示成"未命名会话"。该显示名靠
  Tinode.isSelfTopicName 特判得出、不在 public 里。集中到这个共享函数,所有调用点都受益。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:45:13 +08:00
Your NameandClaude Opus 5 02b98cceae 修复全页面走查发现的问题(第二批:登录、通讯录、群聊)
第1章
- tinode-web: 手输密码登录失败会 navigateTo('') 把用户弹回企业码页,刚输的账号丢了,而
  TenantEntry 不渲染 FormAlert,那句错误提示根本看不到。改成只有存储的 token 失效才回登录入口。
- auth-onboarding IdentityCheck: 限流 429 时服务端会连同未过期的 challenge_id 一起返回,
  原来只显示错误文案、challengeId 留空,于是 submit 开头的守卫静默吞掉点击——输完验证码点
  "确定"毫无反应也无提示。同文件 VerificationButton 早就处理对了,这里是漏抄。
- auth-onboarding Register: 手机号注册路径硬传 invite_code:'',把用户填的邀请码丢了。后端
  user.go:175-181 对邀请码的校验和 RecordUse 不区分注册路径,UI 上也摆着这个输入框。

第5章
- 好友列表主副行显示同一串字:后端在有备注时把 display_name 也设成备注(im_http.go:8500-8507),
  两个字段同值。加"不同才显示"的守卫。
- 拉黑确认弹窗把设置页当背景渲染并摆出"操作之后"的样子,但背景组件自己的 getUserProfile
  回来后又把开关改回未拉黑、"删除好友"红条重新冒出来。加 pinBlocked 挡住这次覆盖。

第6章
- 群列表把群名显示成 topic id、成员数恒为 0。根因在后端:handler 用 store.Users.GetSubs,而
  store 自己的注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),
  groupListItemPayload 却去读 sub.GetPublic()/GetSubCnt()(应该用 GetTopics)。已记为后端问题;
  前端先用本地 Tinode 会话缓存兜底(消息首页一直这么取,是准的)。
- 禁言页每行头像画成群组图标(Avatar 兜底图标默认 'group',没传 icon="person"),
  且顺带从成员表带上真实头像。
- 禁言页"以上成员不允许发言"被当成空状态用,语义正好反了——有禁言成员时这句消失、
  一个都没有时反而显示。改为列表下方常驻注脚,空态另给文案,并补 .group-inline-note 样式。
- bytesToGB 只输出到 MB,1.4GB 的文件显示成 "1433.6 MB"。
- 群文件"来自"直接显示裸 uploader_id(groupFilePayload 不给昵称),拉成员表交叉引用。
- 群公告从聊天页横幅进入后返回,会把会话一起关掉:navigate 只写 query 参数,path[1] 仍为空,
  handleHashRoute 就把 topicSelected 清成 null。Header 支持 onBack,显式把 topic 写回 path[1]。
- 群成员点头像打不开非好友资料页:目标页先读 'profile-seed:' 缓存,因为 getUserProfile 对
  非好友直接 403。第5章入口一直有 seed,这里漏了。
- 群简介上限 225 超过后端硬限 200(im_http.go:4667),写满必被打回。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:40:53 +08:00
Your NameandClaude Opus 5 5a07bb4e3b 修复全页面走查发现的 P0 前端问题(第一批)
八个 agent 对 11 章逐页对照设计稿+后端源码后,先修其中"功能不可用/崩溃/谎报成功"级别的:

崩溃与数据错误
- content-library-view: Highlight 按 [start,len] 数组解构后端返回的 {start,end} 对象,
  对象不可迭代会直接抛 TypeError,任何文本搜索命中都让整个结果区渲染崩掉。服务端偏移还叠加
  两个问题(Go 字节下标 vs JS UTF-16;偏移基准是全文但 snippet 会被开窗截断),改为在展示
  文本上本地匹配关键词。
- content-library-view: firstAttachment 用 Drafty.attachments,SDK 里硬过滤 tp=='EX'
  只认文件附件,图片(IM)/视频(VD)/语音(AU) 一个都取不到——收藏缩略图、语音播放源、聊天记录
  图片视频宫格全部拿不到资源。改用 Drafty.entities 按类型分派(chat-message.jsx 已有先例)。
- message-center-view: 系统通知的机器码兜底会把真实数据吞掉(日报提醒的 summary 就是日期
  字符串 "2026-08-20",会议通知的 summary 是标题),改为查表命中才替换。
- message-center-view: 会话列表漏了 avatar 字段,每行都只能落到兜底图标;同文件
  frequentContacts() 本来就是对的,照它补上。

功能不可用
- auth-onboarding-view: 首次开启青少年模式是死路——设完密码丢弃了服务端返回的
  enable_action_token,从不调 enableYouthMode,用户走完短信验证+设密码后模式仍是关的。
  现在拿着这个 token 把最后一步做完。
- group-management-view: "投诉/举报"是个 onToast('暂未开放') 死桩,而 target_type='group'
  这条链路早就通了。接上真实举报页。
- tinode-web: 刷新/深链会被弹回首页。两个原因叠加:初始化白名单还是上游 Tinode 的
  ['cred','reset','register'];handleLoginSuccessful 无条件把 hash 改写成 workbench,
  刷新触发的自动登录也走这条。抽出 SIDE_PANEL_VIEWS 常量复用,并区分登录流程与自动登录。
- widgets/meta-message: deleted 分支从未实现,撤回一条消息后时间线上什么都不剩。

谎报成功 / 判定错误
- tinode-web + personal-settings-view: 改密码不返回 Promise,服务端拒绝时照样弹"密码已更新"。
- group-management-view: 禁言批量读 result.succeeded,但该接口返回的是 {topic, items},
  succeeded 恒为 undefined,不管成没成都弹"设置成功"。两个接口结构不同,分开判定。
- workplace-collaboration-view: 会议取消状态写成 'cancelled',后端是 'canceled' 单 l,
  会议取消后"取消会议"按钮还挂着。

样式与显示
- base.css: 删掉旧版 .organization-department 的 padding-left !important,它压过按层级算出的
  内联缩进,导致部门行全平、只有人员行缩进。
- base.css: 补 .youth-password-gate(原来 0 条规则),PIN 框缺 position:relative 和居中。
- security-support-view: shield_lock 不在经典 Material Icons 里(只有 Symbols),会显示成
  字面文本;.risk-info-page 也是 0 条规则,整页渲染成裸项目符号列表。改用 verified_user 并
  补上 .risk-dialog。
- contact-management-view: 好友资料页性别直接输出后端枚举,显示成 "male"。
- workplace-collaboration-view: 层级标注两条路径两种格式(树形深三层变"3级"、搜索硬传"三"),
  统一成一个 helper。

已记录不在前端修的后端缺口:recallMessage 的 version 只能传 1,因为所有消息读取接口
(messagePayload/messageSearchPayload/mediaMessagePayload/favoriteSnapshot)都不下发 version,
冲突错误里也不回传真实值,被编辑过的消息撤回必然失败。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:26 +08:00
Your NameandClaude Opus 5 c4189646f4 消息首页:群聊头像图标、昨天的时间格式、常联系的人名字截断
对照 docs/images/消息.png 逐项核对消息首页,实测 DOM 后确认三处:

1. 群聊会话的兜底头像画成了单人像:normalizeConversations 没给任何会话设 icon,全部落到
   Avatar 的默认值 person,列表里分不出哪条是群。按 topic 的 grp 前缀区分,群用 group 图标。
   顺带把群的 online 传 undefined——群不存在"在线"概念,之前会给群也渲染一个状态点。

2. 昨天的会话只显示"昨天",参考图是"昨天21:03"带具体时间。实测修复后显示"昨天17:40"。

3. "常联系的人"的名字被截断:格子 3.75rem(50px),而 "kcg051307" 这类用户名需要 52px,
   显示成 "kcg0513...";参考图里这一栏名字是完整显示的。加宽到 4.4rem,实测 59px 可见宽、
   59px 实际宽,不再截断。

另外记录一个未改的观感偏差供决策:这一栏的头像实测占视口宽 10.1%(42.7px / 424px),参考图
里约占 17%,明显小一号。放大属于观感调整不是缺陷修复,没有自行改动。

本轮还做了一次全局层叠扫描(遍历 19 个页面,自动检测图标在容器内不居中、文字被压成 0 高度、
元素溢出视口、意外截断四类问题):除上面这处截断外没有新命中,说明之前群设置页那组 CSS 层叠
冲突是孤立的,没有蔓延到其他页面。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:09:00 +08:00
Your NameandClaude Opus 5 0ba3577c96 群头像图标贴顶不居中;成员格子文字区过窄
用户实机反馈的三点,逐项查证后:

1. 图标垂直不居中(真bug,已修):.group-settings-header span 这条规则把 .group-avatar 一起
   命中了——它本身就是个 span,被改成 flex-direction:column 之后,自己的 place-items:center
   只剩交叉轴(水平)生效,主轴(垂直)退回 flex-start,图标贴着方块顶部。实测头部群头像上边距
   0px、下边距 28.7px,左右各 14.3px,正好对应这个成因。选择器改为
   > span:not(.group-avatar) 精确排除;同时给 .group-avatar 补 align-content/justify-content,
   以后再被外部规则改成 flex 也不会散架。修完三处(头部群头像、成员头像、添加格)实测上下左右
   边距全部对称。

2. 文字区过窄只显示两个字(真问题,已修):上一版把成员格子宽度收到与头像等宽(3rem),昵称
   被截成"测试..."。按参考图比例重算——那张图里头像约占格子宽七成、相邻头像间隙约头像宽四成,
   且五字昵称完整显示——格子改 4.4rem、头像改 3.6rem、间距 0.5rem。实测"测试昵称A"完整显示
   不再截断。

3. 头像配色:上一版我把兜底头像从饱和蓝底白图标改成了浅蓝底蓝图标,这是自作主张、用户没要求,
   已还原成原来的 #4389df 底 + 白色图标。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:54:22 +08:00
Your NameandClaude Opus 5 f7bc98811e 群聊设置:重做成员预览区,修掉错位间距、多余横线和被压扁的名字
用户实机截图反馈这块"图标和间距有问题"。对照 docs/images/群聊设置.png 逐项查证,实际有四个独立
缺陷叠在一起:

1. 多余横线:成员容器 div 和邀请按钮都是 section 的直接子元素,命中了通用列表项样式
   (.group-settings section > button/div 带 border-bottom + min-height + padding),
   于是这一块被当成两个普通设置行渲染,凭空多出两截下划线。
2. 间距错位:原来是 flex 布局,成员列表 flex:1 占满剩余宽度,把邀请按钮一路挤到最右,两者之间
   出现一大片空白;参考图是等宽网格。
3. 名字看不见:全局 button 规则(base.css:1999)写死 height/line-height 2.2rem,成员格子没有
   覆盖它,整个格子被压到 29px 高——48px 的头像直接溢出,名字被压成 0 高度,实测
   getBoundingClientRect().height 为 0。
4. 图标撞车:成员头像的兜底图标沿用了 Avatar 的默认值 group(双人图标),跟上方群头像长得一模
   一样,分不清哪个是群哪个是人;改传 icon="person"。

改成参考图的结构:一行"群成员"标题(右侧显示人数)+ 5 列等宽网格,最后一格是与头像同尺寸、
同圆角的浅色"添加"块。新样式用 .group-settings section > .group-member-* 提高特异性,明确压过
那套列表项规则,并显式写 height:auto 覆盖全局 button 高度。

刻意没做的两点,都是因为本分支没有对应页面,不想做点了没反应或跳错地方的入口:参考图里"查看
15465群成员"那个可点链接(没有通用群成员列表页,这里只展示人数文字),以及"删除"格(没有移除
成员的页面)。这两个缺口需要单独补页面,不在这次范围内。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:43:22 +08:00
Your NameandClaude Opus 5 8225848493 系统消息:按通知自身内容决定入口和文案,不再一律显示"前往安全设置"
用户实机测试反馈:系统消息列表里每条通知底部都是同一个"前往安全设置"按钮,点进去跟通知内容毫不
相关;正文还直接显示 "group.settings.updated" 这种原始事件码。查证后两个问题性质不同:

入口按钮(前端问题,已修):原来无条件对所有通知渲染同一个按钮、固定跳 security 页。改为按通知
自己的 action 字段决定——action.topic 是 grp 开头的进对应群聊设置页,好友申请类(action.type
为 friend_request)进"新朋友"页,带 device/ip 的安全类才进安全设置页,都不匹配就不显示按钮。
注意 action.topic 不能直接当群用:好友申请通知的 action 里带的是 p2p 会话 topic
(db/mysql/tenant_adapter.go:8643),所以判断加了 grp 前缀检查。跳转目标都核对过在
tinode-web.jsx 的路由白名单里(friend-requests / group-settings),没有凭空造路由名。

正文文案(后端问题,前端只做兜底):后端多处把机器码直接当 summary 写进通知——群事件统一写
summary=event.EventType(im_group_events.go:42-44,7 种群事件还共用同一个 title"群聊动态"),
好友申请处理结果写 summary=status(im_http.go:5956 等,值就是 "accepted"/"rejected")。前端加
了一层兜底:summary 命中"纯小写字母数字点下划线"这种机器码特征时,按已知码翻译成中文,翻译不了
就退回通用文案;后端以后补上真正的文案会自动优先使用,不会被兜底覆盖。这条已记入后端问题清单。

实机验证:两条群通知正文由 "group.settings.updated" 变为"群设置有更新"、按钮变为"查看群聊"且
点击后正确进入 grpXDkK5cbXoK0 的群聊设置页;两条好友通知正文由 "accepted" 变为"对方已通过你的
好友申请",不再显示无关的安全设置按钮。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:32:36 +08:00
Your NameandClaude Opus 5 3bdb0bf40f 群昵称改用专门的接口读取,修好读不回已保存值的问题
上一个commit修好了"群资料"页把群名称当成个人群昵称显示的问题,但实机验证发现改完仍然显示"未设置"。
抓包确认根因:GET /groups/{topic}/members 列表接口的每一项里压根没有 group_nickname 字段(只有
acs/display_name/mentionable/role/user_id),所以任何从成员列表里读昵称的写法,不管代码逻辑对不对,
都永远拿不到值——这个文件里原有的 MyNicknameEditor 也是同样的写法,同样存在这个问题(打开编辑器时
不会回填已保存的昵称)。

真正能读到昵称的是保存用的那个路径上对应的 GET:GET /groups/{topic}/members/me/profile,返回
{nickname, version, updated_at}(server/im_http.go:4776-4782)。im-api.js 里之前只封装了 PUT,
补上了对应的 getMyGroupProfile,两处读取(群资料页预览行、昵称编辑器回填)都改成用它。

实机验证:保存"测试昵称A"后重新打开群资料页,"群昵称"正确显示"测试昵称A","群名称"独立显示
"扫码测试群"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:20:36 +08:00
Your NameandClaude Sonnet 5 e4c6e1b246 Fix group-profile nickname preview showing group name instead of my own nickname
GroupProfile's "群昵称" preview row showed group.name -- the group's own display name -- instead
of the current user's personal nickname within that group (a real, distinct concept, correctly
handled by the existing MyNicknameEditor/group-my-nickname page, just not wired into this preview
row). Ported the substance of a bug dev independently found and fixed in their own reimplementation
of this same page ("show saved group nickname", found while evaluating what from dev's post-fork
commits was worth bringing over). Reuses the same getGroupMembers+filter-by-myUserId approach
MyNicknameEditor already uses in this file (there's no working "me" shortcut on the
member-profile-by-id endpoint that would let this be fetched more directly -- confirmed by reading
server/im_http.go's routing, which never special-cases that literal string).

Also renamed the adjacent "群昵称"->"群名称" label for the group's own display name field, since
having two rows both labeled "群昵称" (one for my personal nickname, one for the group's name) is
exactly the confusion that caused this bug in the first place.

Evaluated the rest of dev's post-fork commits (9 commits fixing this same page's nickname-save
reliability, plus the wholesale chapter-view reimplementation) for anything else worth porting:
the version-conflict retry logic they built doesn't apply here since this branch already sends
version:0 (confirmed server/im_http.go:4793 treats <=0 as "skip the check" -- the exact mechanism
their fix was working around a different way), and their nickname maxLength bump (11->20) is
already exceeded by this branch's existing 32, which matches the real backend limit
(server/im_http.go:4789). Nothing else from dev's branch was found applicable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:08:38 +08:00
Your NameandClaude Sonnet 5 2c77a8e229 Ignore local .claude/ dev-server config
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:01:13 +08:00
45 changed files with 6126 additions and 1034 deletions
+1
View File
@@ -5,3 +5,4 @@ webapp.code-workspace.code-workspace
# Local developer overrides — never commit real keys.
.env.local
.claude/
+581 -56
View File
@@ -653,21 +653,11 @@
color: #8b929b;
}
.organization-root > .material-icons:first-child {
color: #2f73d2;
}
.organization-department {
padding-left: 1.75rem !important;
}
.organization-department > .material-icons:first-child {
color: #e79a32;
}
.organization-members button {
padding-left: 2.5rem;
}
/* 旧版组织架构的 .organization-root / .organization-department / .organization-members 三条规则
已删除:前两个类名 JSX 里早就没人用了,而 .organization-department 那条
`padding-left: 1.75rem !important` 会压过新版按层级算出来的内联 paddingLeft,
导致一级/二级/三级部门行左边距完全一样、树形层级看不出来(人员行没有 !important,缩进正常,
结果是"部门全平、人员缩进",比不缩进更乱)。新版完整规则在下面 .organization-department 那一块。 */
.organization-members img {
width: 2.5rem;
@@ -3653,12 +3643,21 @@ img.channel {
justify-content: space-between;
padding: 0.6rem 0;
}
/* 从只读 div 改成可输入的留言框后,要显式清掉 input 的原生边框/宽度,否则会缩成一个小方框。 */
.batch-forward-comment {
color: var(--clr-text-secondary);
background-color: var(--clr-background-blue-gray-50);
border-radius: 0.4rem;
padding: 0.6rem;
box-sizing: border-box;
width: 100%;
margin: 0.5rem 0;
padding: 0.6rem;
border: 0;
border-radius: 0.4rem;
color: var(--clr-text-primary);
background-color: var(--clr-background-blue-gray-50);
font: inherit;
}
.batch-forward-comment::placeholder {
color: var(--clr-text-secondary);
}
.batch-forward-actions {
display: flex;
@@ -3696,11 +3695,201 @@ img.channel {
}
#send-message-wrapper {
position: relative;
background-color: var(--clr-background-blue-gray-50);
box-shadow: 0px -2px 3px -1px var(--clr-shadow-panel-divider); /* Faint shadow above panel */
z-index: 2;
}
#mention-picker {
position: absolute;
left: 0.5rem;
right: 0.5rem;
bottom: 100%;
margin-bottom: 0.3rem;
max-height: 12rem;
overflow-y: auto;
background: #fff;
border-radius: 0.4rem;
box-shadow: 0 -0.2rem 0.6rem rgba(0,0,0,0.15);
z-index: 3;
}
.mention-picker-row {
padding: 0.6rem 0.9rem;
cursor: pointer;
}
.mention-picker-row.active,
.mention-picker-row:hover {
background: var(--clr-background-blue-gray-50);
}
.mention-picker-empty {
padding: 0.6rem 0.9rem;
color: var(--clr-text-tertiary);
}
#emoji-panel, #attach-panel {
flex: none;
background: #fff;
border-top: 1px solid var(--clr-shadow-panel-divider);
max-height: 12rem;
overflow-y: auto;
}
#emoji-panel-tabs {
display: flex;
padding: 0.4rem 0.6rem 0;
}
#emoji-panel-tabs a {
padding: 0.3rem 0.8rem;
font-size: 1.3rem;
border-bottom: 2px solid transparent;
text-decoration: none;
}
#emoji-panel-tabs a.active {
border-bottom-color: var(--clr-primary-accent);
}
#emoji-panel-grid {
display: flex;
flex-wrap: wrap;
padding: 0.4rem;
}
#emoji-panel-grid a {
width: 2.4rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
text-decoration: none;
}
#emoji-panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.8rem 0.6rem;
border-top: 1px solid var(--clr-shadow-panel-divider);
}
#emoji-panel-footer a {
display: flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
text-decoration: none;
color: var(--clr-text-tertiary);
}
/* 同一色相、不同深浅区分"发不出去"和"能发"两态,不是灰色/彩色那种对比——跟本项目其它地方
记录过的"发送按钮禁用态浅蓝、启用态深蓝"是同一条设计判断。 */
#emoji-panel-footer a.emoji-panel-send {
color: light-dark(#90caf9, #4a7ba6);
}
#emoji-panel-footer a.emoji-panel-send.active {
color: var(--clr-primary-accent);
}
.emoji-panel-empty {
width: 100%;
padding: 1rem;
text-align: center;
color: var(--clr-text-tertiary);
}
#attach-panel {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.6rem;
padding: 1rem 0.6rem;
}
#attach-panel a {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--clr-text-primary);
text-decoration: none;
}
#attach-panel a .material-icons {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--clr-background-blue-gray-50);
font-size: 1.5rem;
}
#attach-panel a.disabled {
opacity: 0.4;
pointer-events: none;
}
.send-message-picker-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: flex-end;
background: rgba(0,0,0,0.35);
}
.send-message-picker-backdrop > section {
width: 100%;
max-height: 70vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 0.6rem 0.6rem 0 0;
}
.send-message-picker-backdrop header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1rem;
font-weight: 600;
border-bottom: 1px solid var(--clr-shadow-panel-divider);
}
.send-message-picker-backdrop header a {
color: var(--clr-text-tertiary);
}
.send-message-picker-list {
overflow-y: auto;
padding: 0.4rem 0;
}
.send-message-picker-list a {
display: block;
padding: 0.75rem 1rem;
color: var(--clr-text-primary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.send-message-picker-empty {
padding: 1.5rem;
text-align: center;
color: var(--clr-text-tertiary);
}
#sendMessage {
overflow: hidden;
overflow-wrap: break-word;
@@ -3766,12 +3955,29 @@ img.channel {
align-items: center;
justify-content: center;
background-color: var(--clr-cyan-50);
}
#dummy-view a {
text-decoration: none;
display: block;
text-align: center;
}
#dummy-view-icon {
display: grid;
place-items: center;
width: 6.5rem;
height: 6.5rem;
margin: 0 auto 0.8rem;
border-radius: 1.2rem;
color: #fff;
background: #2878d6;
}
#dummy-view-icon .material-icons {
font-size: 4.2rem;
}
#dummy-view h2 {
margin: 0;
font-size: 1.25rem;
}
#dummy-view p {
margin: 0.25rem 0 0;
color: #777f89;
}
#image-preview {
position: absolute;
@@ -3914,6 +4120,18 @@ img.channel {
white-space: nowrap;
}
.call-pill-row {
/* Wraps .call-pill and its restore button as plain flex siblings (row, shrink-to-content) so the
button can sit right next to the pill using ordinary flow instead of position:absolute --
#video-container is already an absolute-positioned flex column (base rule, for the fullscreen
video layout) and fighting that from a descendant with more absolute positioning turned out to
size unpredictably (a container with a mix of flex/absolute descendants doesn't reliably
shrink-wrap the way a single plain flex child does). This wrapper is that one simple child. */
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-minimized button.full-screen {
position: static;
width: 1.5rem;
@@ -4148,6 +4366,15 @@ video {
display: inline-block;
}
/* Plain status line under the name badge (not another pill) -- matches how
call-experience-view.jsx's own demo renders the same "正在等待对方接受邀请…" copy. */
.caller-card .caller-status {
margin-top: 0.5rem;
color: var(--clr-bright);
font-size: 0.9rem;
opacity: 0.8;
}
.call-party.peer .caller-name.inactive {
position: absolute;
font-size: larger;
@@ -4215,6 +4442,18 @@ video {
justify-content: flex-end;
}
.voice-unread-dot {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #e3444d;
border: 1.5px solid var(--clr-background-primary, #fff);
z-index: 1;
}
.bubble {
position: relative;
display: flex;
@@ -5258,6 +5497,31 @@ ul.tabbar li.active > a {
margin-right: 0;
}
.audio-hold {
flex: 1;
justify-content: center;
gap: 0.6rem;
}
.audio-hold canvas {
width: 6rem;
margin-right: 0;
}
.audio-hold-hint {
color: var(--clr-text-tertiary);
font-size: 0.85rem;
}
.audio-hold.cancelling {
background-color: var(--clr-danger-text, #d93025);
}
.audio-hold.cancelling .duration,
.audio-hold.cancelling .audio-hold-hint {
color: #fff;
}
.audio .duration {
font-size: large;
}
@@ -5773,6 +6037,23 @@ div.toast.show {
font-size: 1.35rem;
}
/* Same left-side position/color as the plain (non-clickable) .auth-field-icon above -- this is
just that icon made clickable (企业注册's credit-code scan icon), so it shouldn't look any
different at rest, only respond to a tap. */
.auth-field-icon-button {
flex: none;
display: flex;
margin: 0 0 0 1rem;
padding: 0;
border: 0;
background: transparent;
color: #4091ff;
}
.auth-field-icon-button .material-icons {
font-size: 1.35rem;
}
.auth-inline-icon,
.auth-icon-button {
display: grid;
@@ -6409,6 +6690,24 @@ div.toast.show {
text-align: center;
}
.auth-modal {
position: relative;
}
.auth-modal-close {
position: absolute;
top: 0.6rem;
right: 0.6rem;
min-width: 0;
height: auto;
margin: 0;
padding: 0.25rem;
color: #9aa1ab;
background: none;
border: 0;
box-shadow: none;
}
.auth-modal h2 {
margin: 0 0 1rem;
font-size: 1.45rem;
@@ -6440,6 +6739,30 @@ div.toast.show {
font-size: 1.8rem;
}
/* 青少年模式的 PIN 页复用了下面的 .auth-pin-boxes / .auth-pin-input / .auth-primary,
但它自己的容器类 .youth-password-gate 原来在整个 base.css 里一条规则都没有。缺的正是
.auth-pin-content 提供的两件事:position:relative(.auth-pin-input 是 position:absolute
+ top:11rem,没有定位祖先就会跑到更外层去,点 PIN 框重新聚焦会点空)和 align-items:center
(.auth-pin-boxes 是 grid,父级的 text-align:center 对它无效,不会居中)。 */
.youth-password-gate {
display: flex;
position: relative;
flex-direction: column;
align-items: center;
width: min(100%, 32rem);
margin: 0 auto;
padding: 3rem 2rem 2rem;
}
.youth-password-gate .auth-primary {
width: min(100%, 24rem);
margin-top: 3rem;
}
.youth-password-gate .auth-text-button {
margin-top: 1.2rem;
}
.auth-pin-boxes {
display: grid;
grid-template-columns: repeat(4, 4rem);
@@ -6710,11 +7033,13 @@ div.toast.show {
.messages-frequent-scroll button {
display: flex;
flex: 0 0 3.75rem;
/* 3.75rem(50px) 装不下用户名这类较长的名字(实测 "kcg051307" 需要 52px),会被截成
"kcg0513...";参考图 docs/images/消息.png 里这一栏的名字是完整显示的。放宽到 4.4rem。 */
flex: 0 0 4.4rem;
flex-direction: column;
align-items: center;
width: 3.75rem;
min-width: 3.75rem;
width: 4.4rem;
min-width: 4.4rem;
height: auto;
min-height: 0;
margin: 0;
@@ -8717,7 +9042,11 @@ div.toast.show {
.people-avatar,
.group-avatar {
display: grid;
/* place-items 只在 grid 下同时管住两个方向;一旦外层规则把它改成 flex,就只剩交叉轴生效。
补上 justify/align-content,让这里在 grid 和 flex 两种情况下都保持居中。 */
place-items: center;
align-content: center;
justify-content: center;
width: 3rem;
height: 3rem;
overflow: hidden;
@@ -8852,7 +9181,8 @@ div.toast.show {
.group-list strong,
.group-request-list strong,
.group-admin-page strong,
.group-muted-list strong {
.group-muted-list strong,
.group-member-picker strong {
line-height: 1.3;
}
@@ -8864,7 +9194,8 @@ div.toast.show {
.group-list small,
.group-request-list small,
.group-admin-page small,
.group-muted-list small {
.group-muted-list small,
.group-member-picker small {
margin-top: 0.2rem;
color: #9299a2;
font-size: 0.75rem;
@@ -9325,7 +9656,8 @@ div.toast.show {
.group-governance section > button,
.group-governance section > div,
.group-profile > button,
.group-profile > label {
.group-profile > label,
.people-profile-qr-row {
display: flex;
align-items: center;
width: 100%;
@@ -9469,6 +9801,17 @@ div.toast.show {
line-height: 1.6;
}
.group-password-input {
width: 100%;
height: 2.75rem;
margin-top: 0.6rem;
padding: 0 0.9rem;
border: 1px solid #dde1e6;
border-radius: 0.35rem;
font-size: 1rem;
text-align: left;
}
.people-confirm-sheet button,
.group-source-sheet button,
.group-confirm-sheet button {
@@ -9554,8 +9897,15 @@ div.toast.show {
text-transform: none;
}
.group-settings-header .group-avatar { width: 3.6rem; height: 3.6rem; }
.group-settings-header span { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.group-settings-header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9299a2; }
/* 必须排除 .group-avatar:它本身也是个 span,被这条规则改成 flex-direction:column 之后,
自己的 place-items:center 就只剩交叉轴(水平)生效,主轴(垂直)退回 flex-start,
里面的图标会贴着方块顶部而不是居中。 */
.group-settings-header > span:not(.group-avatar) { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
/* Same generic `button { line-height: 2.2rem }` leak as .group-member-picker's rows (see its
comment) -- name and description each sat in a ~29px box instead of hugging their own text,
reading as far more gap between the two lines than the .15rem `gap` above actually specifies. */
.group-settings-header strong { line-height: 1.3; }
.group-settings-header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9299a2; line-height: 1.3; }
.group-list > button {
display: grid;
@@ -9882,24 +10232,53 @@ div.toast.show {
color: #3a8aec;
}
.group-member-strip {
/* 群聊设置页的成员预览网格(参考 docs/images/群聊设置.png)。这里必须用比
".group-settings section > div" 更高的特异性覆盖那套列表项样式(flex + min-height +
border-bottom + padding),否则网格容器会被当成一行普通设置项渲染,出现整行下划线;
同理成员格子刻意放在 grid 容器内部,避免成为 section 的直接子元素而命中同一套样式。 */
.group-settings section > .group-member-section-title {
min-height: 2.8rem;
padding: 0.75rem 1rem 0.35rem;
border-bottom: 0;
gap: 0.5rem;
}
.group-member-section-title > strong {
color: #30353c;
font-size: 1rem;
line-height: 1.3;
}
.group-member-section-title > small {
margin-left: auto;
color: #949ba4;
line-height: 1.3;
}
.group-settings section > .group-member-grid {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
padding: 1rem;
/* 不用 5 等分栅格:成员少的时候每个格子仍占满 1/5 宽度,头像被各自居中在很宽的格子里,
相邻两个头像之间会空出约头像宽度的间隙,看着很散。改成固定格子宽 + 固定间距靠左排。
尺寸按 docs/images/群聊设置.png 的比例反推:那张图里头像约占格子宽的 70%、相邻头像间隙
约为头像宽的 40%,且五个字的昵称能完整显示不截断——所以格子要比头像明显宽出一截,
光把格子缩到和头像等宽会让昵称被截成"测试..."。 */
gap: 0.9rem 0.5rem;
min-height: 0;
padding: 0.25rem 1rem 1rem;
border-bottom: 0;
}
.group-member-strip > div {
display: flex;
gap: 0.8rem;
overflow-x: auto;
flex: 1 1 auto;
}
.group-member-strip button {
/* 全局 button 规则(css/base.css 约 1999 行)写死了 height: 2.2rem 和同值的 line-height,
不显式覆盖的话这里的格子会被压到 29px 高——头像溢出、下面的名字被压成 0 高度直接看不见。 */
.group-member-grid > button {
display: flex;
flex-direction: column;
align-items: center;
width: 3.2rem;
min-width: 3.2rem;
gap: 0.35rem;
width: 4.4rem;
min-width: 0;
height: auto;
min-height: 0;
margin: 0;
@@ -9908,19 +10287,43 @@ div.toast.show {
box-shadow: none;
color: #68707a;
background: transparent;
flex-direction: column;
line-height: 1.3;
text-transform: none;
}
.group-member-strip button > small {
.group-member-grid > button > small {
width: 100%;
margin-top: 0.3rem;
overflow: hidden;
color: #68707a;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 成员格子里的头像比默认 3rem 略大,才能撑起参考图里"头像占格子七成、间隙约头像四成"的比例;
与之配套,"添加"格也用同一尺寸。 */
.group-member-grid .group-avatar,
.group-member-tile-action {
width: 3.6rem;
height: 3.6rem;
flex: 0 0 3.6rem;
}
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开 */
.group-member-tile-action {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 0.45rem;
color: #98a0aa;
background: #f0f2f5;
}
.group-member-tile-action > .material-icons {
font-size: 1.5rem;
}
.group-profile {
width: min(100%, 50rem);
margin: 0 auto;
@@ -9932,7 +10335,11 @@ div.toast.show {
gap: 0.7rem;
}
.group-profile > button > span,
/* :not(.group-avatar) exclusion needed for the same reason as .group-settings-header's own
(see its comment): this selector is scoped only by tag+ancestor, so without it also matches
.group-profile-avatar's leading Avatar span and forces it to the label column's 6rem min-width
-- stretching what should be a small square avatar into an 80px-wide rectangle. */
.group-profile > button > span:not(.group-avatar),
.group-profile > label > span {
min-width: 6rem;
}
@@ -9962,10 +10369,15 @@ div.toast.show {
height: 3.5rem;
}
.group-profile > p {
padding: 0.7rem 1rem;
color: #8c939c;
font-size: 0.76rem;
/* 群头像/群二维码这两行没有其它行都有的 <small>(本身靠 margin-left:auto 把末尾图标推到
最右)——原来两行的图标紧贴在文字标签后面,跟其它行对不齐,列表右侧参差不齐。直接把
auto margin 放到"最右边这一组图标"里最靠前的那一个身上,这是更明确的写法,不依赖前面
兄弟元素的间接推挤:群头像只有末尾箭头一个图标,加在它自己身上;群二维码是"二维码小图标
+箭头"两个连续图标要整组贴右,auto margin 只能放在这一组的第一个(二维码图标)身上——
放在箭头身上只会把箭头自己推到最右,二维码图标依然留在文字标签后面,两个图标就分家了。 */
.group-profile-avatar > i:last-child,
.group-profile-qr > i:first-of-type {
margin-left: auto;
}
.group-edit-text {
@@ -10056,6 +10468,15 @@ div.toast.show {
text-align: center;
}
/* 列表下方的常驻灰色注脚(禁言页的"以上成员不允许发言"),区别于上面的空状态文案。 */
.group-inline-note {
margin: 0;
padding: 0.9rem 1rem 1.4rem;
color: #969da5;
font-size: 0.85rem;
text-align: center;
}
/* Alphabet-group section header inside MemberPicker/OwnerSelect, same treatment as .group-admin-page h2. */
.group-section-title {
margin: 0;
@@ -10153,11 +10574,6 @@ div.toast.show {
flex-direction: column;
}
.group-member-picker small {
margin-top: 0.2rem;
color: #9299a2;
}
.group-check {
display: grid;
place-items: center;
@@ -10543,6 +10959,28 @@ div.toast.show {
font-style: normal;
}
/* Shared by VoiceCard/LinkText/MediaThumb's "removed" state (content-library-view.jsx) -- same
opacity + red label language as .library-file-card.removed/em above, but these three don't share
its grid layout (voice/media assume an icon+waveform or an image backdrop that isn't there once
the source message is gone), so the removed state uses a simple centered flex row instead. */
.library-voice-card.removed,
.library-link-text.removed,
.library-media-thumb.removed {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
opacity: 0.65;
}
.library-voice-card.removed em,
.library-link-text.removed em,
.library-media-thumb.removed em {
color: #d74a53;
font-size: 0.74rem;
font-style: normal;
}
.library-voice-card {
display: grid;
grid-template-columns: 2rem minmax(4rem, 12rem) auto;
@@ -12187,7 +12625,16 @@ div.toast.show {
border-radius: unset;
box-shadow: unset;
}
}
/* .call-party.self's fullscreen self-preview (bottom-right corner) overlaps #controls' centered
button row somewhere around 641px-750px: narrower than this and the row above already switches to
the cramped mobile layout (with its own bottom:8.5rem clearance built in below); wider and #controls
has enough room to stay clear of the preview on its own. 768px is comfortably past where the overlap
was verified to stop, and is a common breakpoint already used elsewhere in this file. Kept as its own
query (rather than folded into the 640px block above) since the rest of that block also restructures
#video-container/#controls into an entirely different mobile layout that shouldn't kick in this early. */
@media (max-width: 768px) {
.call-party.self {
bottom: 8.5rem;
border-radius: unset;
@@ -12931,6 +13378,13 @@ div.toast.show {
background: #d8dce1;
}
/* 企业强制策略锁死的项(后端 privacy.editable 里为 false)要看得出是不可改的,
否则用户只会觉得开关点不动。 */
.personal-switch:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.personal-switch i {
position: absolute;
top: 0.15rem;
@@ -13383,6 +13837,16 @@ div.toast.show {
text-align: center;
}
/* .risk-dialog was designed as a modal card, centered by .risk-overlay's place-items:center wrapper
(RiskConfirmDialog). FraudAwareness reuses the same class directly as .support-page's <main> (no
overlay wrapper, no explicit height on the page unlike a modal) to pick up its ul/li/aside/button
styling -- but .support-page is a plain flex column with no centering of its own, so the capped
width left the whole page pinned to the left edge on anything wider than ~25rem instead of centered
like every other support-page/group-page/people-page in the app. */
.risk-info-page {
margin: 0 auto;
}
.risk-symbol {
display: grid;
place-items: center;
@@ -14175,3 +14639,64 @@ div.toast.show {
color: #fff;
background: #347bd7;
}
/* 合并转发卡片(widgets/forward-bundle.jsx,第4章 4.20)。
服务端 mode="bundle" 生成的消息不是 Drafty,单独渲染成一张可展开的"聊天记录"卡片。 */
.forward-bundle {
min-width: 11rem;
max-width: 18rem;
}
.forward-bundle-title {
margin-bottom: 0.35rem;
font-weight: 500;
}
.forward-bundle-lines {
margin: 0;
padding: 0;
list-style: none;
}
.forward-bundle-lines li {
overflow: hidden;
margin: 0.15rem 0;
color: var(--clr-text-secondary);
font-size: 0.85rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.forward-bundle-lines li b {
font-weight: 500;
}
.forward-bundle-more,
.forward-bundle-count {
display: block;
width: 100%;
margin: 0.4rem 0 0;
padding: 0.35rem 0 0;
border: 0;
border-top: 1px solid var(--clr-divider, #e7e9ec);
border-radius: 0;
box-shadow: none;
background: none;
color: var(--clr-text-secondary);
font-size: 0.8rem;
line-height: 1.3;
text-align: left;
text-transform: none;
}
.forward-bundle-more {
color: #347bd7;
cursor: pointer;
}
.forward-bundle-comment {
margin-top: 0.4rem;
color: var(--clr-text-primary);
font-size: 0.85rem;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,7 +1,8 @@
import { PACKAGE_VERSION } from './version.js';
// Version-independent name of this application, used for Firebase app name.
export const BASE_APP_NAME = 'TinodeWeb';
// Version-independent name of this application, used for Firebase app name and shown to users
// (about page, youth-mode copy).
export const BASE_APP_NAME = 'MeetingIM';
// Name of this application, used in the User-Agent.
export const APP_NAME = BASE_APP_NAME + '/' + (PACKAGE_VERSION || '0.25');
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -614,7 +614,7 @@
"obsolete": false
},
"last_seen_timestamp": {
"translation": "Last seen",
"translation": "Last seen: {timestamp}",
"defaultMessage": "Last seen: {timestamp}",
"description": "Label for the timestamp of when the user or topic was last online",
"missing": false,
+6 -6
View File
@@ -614,7 +614,7 @@
"obsolete": false
},
"last_seen_timestamp": {
"translation": "最后可见",
"translation": "最后在线:{timestamp}",
"defaultMessage": "Last seen: {timestamp}",
"description": "Label for the timestamp of when the user or topic was last online",
"missing": false,
@@ -852,7 +852,7 @@
"obsolete": false
},
"peers_messaging_disabled": {
"translation": "成员间消息已禁用。",
"translation": "对方已关闭消息。",
"defaultMessage": "Peer's messaging is disabled.",
"description": "Shown when the p2p peer's messaging is disabled",
"missing": false,
@@ -985,7 +985,7 @@
"obsolete": false
},
"search_no_results": {
"translation": "搜索返回任何结果",
"translation": "搜索无结果",
"defaultMessage": "Search returned no results",
"description": "Text shown in contacts view when query returned no results.",
"missing": false,
@@ -1314,14 +1314,14 @@
"obsolete": false
},
"menu_item_forward": {
"translation": "重定向",
"translation": "转发",
"defaultMessage": "Forward",
"description": "Forward message",
"missing": false,
"obsolete": false
},
"forward_to": {
"translation": "重定向消息",
"translation": "转发给",
"defaultMessage": "Forward to",
"missing": false,
"obsolete": false
@@ -1495,7 +1495,7 @@
"obsolete": false
},
"icon_title_send": {
"translation": "发信息",
"translation": "发送消息",
"defaultMessage": "Send message",
"description": "Icon tool tip for sending a message",
"missing": false,
+5
View File
@@ -71,6 +71,11 @@ export function fullFormatter(style, data, values, key, stack) {
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
attr.preview = data.preview;
attr.loading = 'lazy';
// 4.9 语音未读红点:chat-message.jsx 的 onVoicePlayed 记录"这条语音真的被点开听过",
// 跟消息本身有没有被标记已读是两件事。favorites/history 等别处复用同一个 formatter
// context 没有这个字段时,attr.onPlay 就是 undefined,AudioPlayer 那边本来就把它当
// 可选 prop 处理,不会因此出问题。
attr.onPlay = this.onVoicePlayed;
}
el = AudioPlayer;
// Audio element cannot have content.
+156 -17
View File
@@ -21,6 +21,12 @@ export class ImApiError extends Error {
// error is constructed fixes all of them at once, rather than needing a matching change at every
// individual catch block scattered across the app.
const REASON_MESSAGES = {
// Real per-account risk is low (this app always sends a hardcoded key, so hitting these means a
// deployment-wide config problem, not something an individual user did) -- but exactly this "the
// whole site is silently misconfigured" scenario has genuinely happened once already (a stale API
// key got redeployed to production), so it's worth a real message instead of raw English site-wide.
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
@@ -30,7 +36,32 @@ const REASON_MESSAGES = {
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
FORBIDDEN: '没有权限执行此操作',
// 后端 server/im_http.go 里 30+ 处 403 用的都是 imReasonPermissionDenied="PERMISSION_DENIED",
// 从未发送过字面量 "FORBIDDEN"——原来的 FORBIDDEN 条目是死代码,全站 403 一直透传英文原文。
PERMISSION_DENIED: '没有权限执行此操作',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
AUTH_REQUIRED: '请先登录',
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
VERIFICATION_RATE_LIMITED: details => details && details.retry_after_seconds
? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试`
: '操作过于频繁,请稍后再试',
ALREADY_GROUP_MEMBER: '对方已经是群成员',
DESCRIPTION_REQUIRED: '请填写简介内容',
DESCRIPTION_TOO_LONG: '简介内容过长',
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
@@ -44,7 +75,14 @@ const REASON_MESSAGES = {
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截'
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
// 独立密码校验的两个 reason(对接文档 809 行有写),青少年模式关闭/开启走的都是这条接口。
// 原来没收录,报错时直接透传后端英文原文——PIN 输错显示 "code invalid",连续错误锁定后
// 显示 "too many attempts",且不告诉用户要等多久能重试。
CODE_INVALID: '独立密码不正确',
TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds
? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试`
: '连续输错次数过多,请稍后重试'
};
function requestId() {
@@ -123,15 +161,47 @@ export async function imRequest(path, {method = 'GET', body, headers, auth = tru
// (not silently replaced with a generic string) since this backend's messages have consistently
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
// reasons this app doesn't otherwise know how to react to fall through to it.
const known = payload.reason && REASON_MESSAGES[payload.reason];
const known = translateReason(payload.reason, payload.details);
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason, response.status, payload.details);
}
return normalizeResponse(payload);
}
// Several batch endpoints (favorites/batch, favorites/forward, messages/forward, ...) always return
// HTTP 200 and bury the real per-item outcome in a nested `items[]`/`failed[]` array instead of
// throwing -- imRequest's translation above never runs for those, since it only fires on a non-2xx/
// non-zero-code response. Exported so call sites reading one of those per-item `reason` strings
// directly (e.g. send-message.jsx's sendFavorite) can reuse the exact same table instead of either
// duplicating it or -- as was actually happening -- showing the raw untranslated reason string.
export function translateReason(reason, details) {
const entry = reason && REASON_MESSAGES[reason];
return (typeof entry === 'function' ? entry(details) : entry) || null;
}
const idem = () => ({'Idempotency-Key': requestId()});
// Most list endpoints here cap out at 20-50 items per page (some as low as 30, e.g. group
// members against a 128-member cap) and round7 testing found several call sites only ever
// fetched the first page -- for things like "am I this group's owner/admin" or the admin
// candidate list, a real owner/admin sitting past page 1 went undetected entirely rather than
// just showing an incomplete list. Empty/repeated next_cursor means done; this app's list
// responses haven't been observed to send a separate has_more that disagrees with that.
export async function fetchAllPages(fetchPage, startCursor = '') {
let cursor = startCursor;
let items = [];
for (;;) {
const page = await fetchPage(cursor);
items = items.concat((page && page.items) || []);
const nextCursor = (page && page.next_cursor) || '';
if (!nextCursor || nextCursor === cursor) {
break;
}
cursor = nextCursor;
}
return items;
}
export const imGet = (path, opts) => imRequest(path, opts);
export const imPost = (path, body, opts = {}) => imRequest(path, {method: 'POST', body, headers: idem(), ...opts});
export const imPatch = (path, body, opts = {}) => imRequest(path, {method: 'PATCH', body, headers: idem(), ...opts});
@@ -191,6 +261,15 @@ export async function uploadPublicFile(file, purpose, onProgress) {
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
// extension suffix) since getFileUrl() already round-trips that exact format unmodified.
// This upload talks to Tinode's native {ctrl} protocol (see the comment above), not the REST layer,
// so REASON_MESSAGES above never applies to it -- confirmed against server/datamodel.go's Err*
// constructors for the literal ctrl.text values a failure here can actually carry.
const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除'
};
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
@@ -215,7 +294,8 @@ export function uploadLargeFile(tinode, file, onProgress) {
const url = ctrl.params.url;
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
@@ -265,7 +345,11 @@ export function createChatUploader(tinode) {
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url);
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
// Same native-{ctrl}-protocol translation as uploadLargeFile above -- REASON_MESSAGES
// never applies here, and a too-large photo/video/file picked from the attachment panel
// is a realistic, easy way for a real user to hit this.
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
@@ -319,7 +403,9 @@ export function createFileDownloader(tinode) {
let err;
try {
const pkt = JSON.parse(this.result);
err = new ImApiError((pkt.ctrl && pkt.ctrl.text) || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
const text = pkt.ctrl && pkt.ctrl.text;
const known = text && UPLOAD_CTRL_TEXT_CN[text.trim().toLowerCase()];
err = new ImApiError(known || text || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
}
@@ -351,12 +437,29 @@ export function resolveTenant(tenantCode) {
});
}
// 企业注册申请 / 入口反馈 / 意见反馈三个接口后端都定义了 client_context 且会入库
// (imTenantApplicationRequest / imTenantEntryFeedbackRequest / imFeedbackRequest,
// server/im_http.go:255/263/367,落库点 :790/:840/:1478),需求文档和对接文档也都要求带上
// 设备与版本信息。前端一直没发。在这里统一注入,省得三处各写一遍、也不会再漏。
function clientContext() {
return {
platform: 'web',
app_version: PACKAGE_VERSION || '',
user_agent: (typeof navigator !== 'undefined' && navigator.userAgent) || '',
language: (typeof navigator !== 'undefined' && navigator.language) || '',
// 网络类型不是所有浏览器都有(Safari/Firefox 没有 navigator.connection),拿不到就不发这一项
network: (typeof navigator !== 'undefined' && navigator.connection && navigator.connection.effectiveType) || undefined
};
}
export function submitTenantApplication(body) {
return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(), body});
return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(),
body: {client_context: clientContext(), ...body}});
}
export function submitTenantEntryFeedback(body) {
return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(), body});
return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(),
body: {client_context: clientContext(), ...body}});
}
// -- 1.4/1.5/1.6 Phone verification codes (register / reset_password / security scenes). --
@@ -449,6 +552,11 @@ export const getConversationSettings = topic => imGet('/conversations/' + encode
export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body);
// -- Messages (Ch.4/7). --
// version 只能默认传 1:服务端做的是乐观锁校验(tenant_adapter.go:1963 要求 msg.Version 与传入值
// 完全相等,否则 ErrDuplicate),但**没有任何消息读取接口下发过 version 字段**——messagePayload /
// messageSearchPayload / mediaMessagePayload / favoriteSnapshot 全都没有,冲突时的错误里也不回传
// 真实值。所以前端拿不到正确的版本号,被编辑过(version>1)的消息撤回必然失败。这是后端缺口,
// 已记入《IM-后端问题清单.md》,不在前端绕。
export const recallMessage = (topic, seq, version = 1, reason = 'mistake') =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason});
export const reviseMessage = (topic, seq, content, version = 1) =>
@@ -469,7 +577,7 @@ export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
export const getFavorites = ({type = 'all', query = '', cursor = ''} = {}) => imGet('/favorites' + qs({type, q: query, cursor}));
export const getFavorites = ({type = 'all', query = '', cursor = '', limit} = {}) => imGet('/favorites' + qs({type, q: query, cursor, limit}));
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
@@ -480,6 +588,13 @@ export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
// 4.1 跨话题草稿:GET 对不存在的草稿返回 {content:'',entities:[],version:0} 而不是 404,PUT 用
// version 做乐观锁(对接文档描述的样子)。真实验证过用原生话题名(P2P 是对方 user_id,不是
// REST 层内部的 p2pXXX id)可以正常读写——这个接口内部会先查 Subs.Get 校验会话存在/有权限,
// 跟 favorites 那条从不做话题归一化、直接 NOT_FOUND 的坏路径不是同一个问题。
export const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
export const updateTopicDraft = (topic, content, entities, version) =>
imPut('/topics/' + encodeURIComponent(topic) + '/draft', {content, entities, version});
export async function fetchFileState(fileId, range = '') {
const headers = {
@@ -515,6 +630,17 @@ export function publicFileBase() {
return fileBase();
}
// Just the URL string -- fileBase() (the tenant's real, possibly cross-origin file domain, e.g.
// "https://api.118im.com/v0/file") + the server-relative path a Drafty ref/upload response carries.
// No fetch. Split out from getFileUrl() below for DownloadPage (content-library-view.jsx): that
// caller needs a correctly-qualified URL to *display and hand to the download button*, not to
// eagerly pull the whole file into a blob the moment the page opens -- getFileUrl()'s eager fetch,
// used unmodified there, silently downloaded every out-of-band attachment in full before the user
// ever pressed "download", defeating the ready/downloading/done UI entirely.
export function resolveFileUrl(fileId) {
return fileId ? fileBase() + '/s/' + encodeURIComponent(fileId) : null;
}
// File serving (GET /v0/file/s/{id}) is auth-gated -- the upload endpoints never return a directly
// servable URL (publicUploadPayload only has file_id/purpose/mime_type/size/expires_at), so a plain
// <img src> can't be pointed at it (browsers don't attach custom headers to img/video/audio requests).
@@ -537,7 +663,7 @@ export function getFileUrl(fileId) {
if (token) {
headers.Authorization = 'Token ' + token;
}
const promise = fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers}).then(res => {
const promise = fetch(resolveFileUrl(fileId), {headers}).then(res => {
if (!res.ok) {
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
}
@@ -561,6 +687,11 @@ export const updateGroupMemberSettings = (topic, {pinned, mute_notifications}) =
imPatch('/groups/' + encodeURIComponent(topic) + '/member-settings', {pinned, mute_notifications});
export const updateMyGroupProfile = (topic, nickname, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/members/me/profile', {nickname, version});
// GET .../members/me/profile is the only endpoint that actually returns the saved nickname --
// confirmed live that GET .../members (getGroupMembers below) never includes group_nickname on any
// item, so anything reading the nickname from that list (as this file's own callers used to) will
// always see it as unset even right after a successful save.
export const getMyGroupProfile = topic => imGet('/groups/' + encodeURIComponent(topic) + '/members/me/profile');
export const leaveGroup = topic => imPost('/groups/' + encodeURIComponent(topic) + '/leave', {});
export const getGroupMembers = (topic, {query = '', cursor = '', role = '', eligibleRole = '', mentionable, muteEligible} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' +
@@ -650,8 +781,10 @@ export const getDailyReport = reportId => imGet('/reports/daily/' + encodeURICom
export const updateDailyDraft = (reportId, content, version, attachments = []) =>
imPut('/reports/daily/' + encodeURIComponent(reportId) + '/draft', {content, attachments, version});
export const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {version});
// department_id is also ignored here -- statistics are always tenant-wide.
export const getDailyStatistics = date => imGet('/reports/daily/statistics' + qs({date}));
// TenantDailyReportStats does honor department_id (reportMemberScopeWhere adds `m.department_id=?`
// when it's non-empty) -- confirmed against current im_be HEAD, this wasn't always true and an earlier
// comment here claimed the opposite.
export const getDailyStatistics = (date, departmentId = '') => imGet('/reports/daily/statistics' + qs({date, department_id: departmentId}));
export const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {date, user_ids: userIds});
export const getMonthlyReports = ({scope = 'mine', fromMonth = '', toMonth = '', status = '', cursor = ''} = {}) =>
imGet('/reports/monthly' + qs({scope, from_month: fromMonth, to_month: toMonth, status, cursor}));
@@ -660,10 +793,16 @@ export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeUR
export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) =>
imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version});
export const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version});
export const reopenMonthlyReport = (reportId, version, reason = '补充内容') =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version, reason});
// department_id is ignored here too -- statistics are always tenant-wide.
export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month}));
// server/im_http.go's serveIMMonthlyReportReopen decodes only {version} (DisallowUnknownFields
// rejects anything else) -- an earlier draft of this function sent an extra `reason` field that
// would 400 MALFORMED_REQUEST on every real call. Also: the store layer's UPDATE has
// `WHERE ... AND author_id=?` keyed off the *caller's* uid, so this only ever lets a report's own
// author reopen their own already-submitted report for further editing -- there is no
// manager-reopens-someone-else's-report capability on the backend.
export const reopenMonthlyReport = (reportId, version) =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version});
// Same department_id support as getDailyStatistics (TenantMonthlyReportStats shares reportMemberScopeWhere).
export const getMonthlyStatistics = (month, departmentId = '') => imGet('/reports/monthly/statistics' + qs({month, department_id: departmentId}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
export const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
@@ -687,4 +826,4 @@ export const updateDevicePushToken = (deviceId, body) => imPut('/devices/' + enc
export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/reasons' + qs({target_type: targetType}));
export const submitAbuseReport = body => imPost('/reports/abuse', body);
export const getFeedbackConfig = () => imGet('/feedback/config');
export const submitFeedback = body => imPost('/feedback', body);
export const submitFeedback = body => imPost('/feedback', {client_context: clientContext(), ...body});
+31 -2
View File
@@ -23,19 +23,34 @@ export function useCachedData(key, fetcher, deps) {
React.useEffect(() => {
if (!key) return;
let live = true;
let retried = false;
// Don't flip back to a loading state just because deps changed while we already have a cached
// value for the new key -- only the truly-never-fetched case should show a loading affordance.
setLoading(!cache.has(key));
fetcher().then(result => {
const attempt = () => fetcher().then(result => {
if (!live) return;
cache.set(key, result);
setData(result);
setLoading(false);
}).catch(() => {
if (!live) return;
// A transient failure on the very *first* fetch for this key -- nothing cached yet to fall
// back on -- used to leave `data` stuck at undefined for the rest of the session, since
// nothing else ever re-triggers this effect unless `key`/`deps` change. One retry after a
// short delay covers the common case (a network blip during initial page load) without
// retry-storming a persistently failing endpoint. A key that already has *some* cached value
// keeps the original behavior (show the stale value, don't retry) -- that case is already
// showing the user something, so it's lower urgency than the stuck-at-nothing case.
if (!retried && !cache.has(key)) {
retried = true;
window.setTimeout(() => { if (live) attempt(); }, 3000);
return;
}
// Keep whatever stale/cached value was already showing rather than clearing it on a transient
// failure -- a blank screen on error is worse than a slightly-out-of-date one.
if (live) setLoading(false);
setLoading(false);
});
attempt();
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ...(deps || [])]);
@@ -53,3 +68,17 @@ export function readCachedData(key) {
export function writeCachedData(key, value) {
if (key) cache.set(key, value);
}
// 搜索框防抖:返回一个滞后于 `value` 的值,只有停止输入 `delay` 毫秒后才更新。
// 用途是把"每敲一个字符发一次请求"降成"停下来才发一次"。收藏搜索、会话内检索、群聊搜索
// 原来都是把 query 直接放进 effect 依赖里,输入 6 个字的关键词就打 6 次接口,
// 而且返回顺序没有保证——后发的短前缀请求可能后到,把正确结果覆盖掉。
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
if (value === debounced) return undefined;
const timer = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(timer);
}, [value, delay, debounced]);
return debounced;
}
+4
View File
@@ -15,6 +15,10 @@ export function topicDisplayName(topicName, publicData, fallback) {
const fn = publicData && publicData.fn;
if (fn) return fn;
if (publicData && Tinode.isGroupTopicName(topicName) && publicData.name) return publicData.name;
// 自聊天(slf)没有 public,原来会一路落到 fallback —— 消息首页因此把它显示成"未命名会话"。
// 它的显示名靠 Tinode.isSelfTopicName 特判得出,不在 public 里。这里集中处理,所有调用点都受益;
// 已经自己判过的地方(batch-forward-dialog 等)在调用前就短路了,不受影响。
if (Tinode.isSelfTopicName(topicName)) return '已保存的消息';
return fallback;
}
+84 -14
View File
@@ -4,7 +4,8 @@ import HashNavigation from '../lib/navigation.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword,
getYouthMode, enableYouthMode
} from '../lib/im-api.js';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
@@ -13,7 +14,9 @@ const INVITE_RE = /^[A-Za-z]{5}$/;
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const navigate = view => HashNavigation.navigateTo(view);
// params is optional and additive only -- every existing call site that passes just `view` keeps
// producing the exact same bare hash as the old `HashNavigation.navigateTo(view)` did.
const navigate = (view, params) => HashNavigation.navigateTo(HashNavigation.composeUrlHash([view], params || {}));
function Icon({children, className = ''}) {
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
@@ -36,10 +39,12 @@ function Header({title, back = 'tenant'}) {
</header>;
}
function Field({icon, action, error, className = '', ...props}) {
function Field({icon, onIconClick, action, error, className = '', ...props}) {
return <div className={'auth-field-wrap ' + className}>
<div className={'auth-field' + (error ? ' invalid' : '')}>
{icon ? <Icon className="auth-field-icon">{icon}</Icon> : null}
{icon ? (onIconClick ?
<button type="button" className="auth-field-icon-button" onClick={onIconClick}><Icon>{icon}</Icon></button> :
<Icon className="auth-field-icon">{icon}</Icon>) : null}
<input {...props} />
{action}
</div>
@@ -236,7 +241,8 @@ function EnterpriseRegister() {
<FormAlert text={errors.form} />
<label className="auth-label">统一社会信用代码</label>
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} />
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode}
onIconClick={() => setToast('扫码识别暂不可用,请手动输入')} />
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
<label className="auth-label">上传营业执照 <span>选填</span></label>
@@ -421,7 +427,11 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
// set a nickname afterwards).
const secretAccount = form.username || ('+86' + form.phone);
onImRegisterRequest(secretAccount, form.password, form.username || '',
{username: form.username || '', phone: form.phone, invite_code: ''},
// 邀请码不能在手机号路径上丢掉:后端 user.go:175-181 对邀请码的校验和 RecordUse 都不区分
// 注册路径(`if inviteCode != ""` 就会校验并记录),而且 user.go:169 的
// inviteRequired 在短信通道关闭时对手机号注册同样为真。UI 上明明摆着邀请码输入框,
// 原来却硬传空串,用户填了也被静默丢弃,邀请来源统计全丢。
{username: form.username || '', phone: form.phone, invite_code: form.invite || ''},
{meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}});
} else {
onImRegisterRequest(form.username, form.password, form.username,
@@ -521,7 +531,13 @@ function PasswordReset() {
// successful verify, stashes the resulting action_token for the next screen to spend.
const IDENTITY_CHECK_SCENE = 'set_independent_password';
function IdentityCheck({authenticated}) {
function IdentityCheck({authenticated, hashParams}) {
// Carries through whichever youth-mode flow (enable/disable) sent the user here via "忘记独立
// 密码?", if any -- IndependentPassword needs it to know whether to auto-continue into actually
// enabling youth mode, since the server hands back an enable-flow token unconditionally on every
// successful password (re)set regardless of why the caller got here. Absent (undefined) for the
// unrelated generic "身份验证" entry in acc-security-view.jsx, which should never touch youth mode.
const mode = hashParams && hashParams.ym;
const [phoneMask, setPhoneMask] = React.useState('');
const [challengeId, setChallengeId] = React.useState('');
const [code, setCode] = React.useState('');
@@ -545,6 +561,20 @@ function IdentityCheck({authenticated}) {
setSeconds(data.resend_after_seconds || 60);
} catch (err) {
setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败'));
// 限流(429)时服务端会连同还没过期的 challenge_id 和剩余冷却秒数一起返回
// (im_http.go:6361-6367)。原来这里只把错误文案显示出来就完了,challengeId 一直是空串,
// 于是 submit() 开头的 `if (!challengeId) return` 会静默吞掉点击——按钮看着能点,
// 输完 6 位验证码点"确定"毫无反应,也没有任何提示。同文件的 VerificationButton
// 早就处理对了,这里是漏抄。
if (err.details && err.details.retry_after_seconds) {
setSeconds(err.details.retry_after_seconds);
}
if (err.details && err.details.challenge_id) {
setChallengeId(err.details.challenge_id);
}
if (err.details && err.details.phone_mask) {
setPhoneMask(err.details.phone_mask);
}
} finally {
setBusy(false);
}
@@ -559,7 +589,7 @@ function IdentityCheck({authenticated}) {
try {
const data = await verifySecurityChallenge(challengeId, code);
sessionStorage.setItem('im-action-token:' + IDENTITY_CHECK_SCENE, JSON.stringify(data));
navigate('independent-password');
navigate('independent-password', mode ? {ym: mode} : undefined);
} catch (err) {
setError(err.message || '验证码错误');
} finally {
@@ -659,7 +689,12 @@ function RealnameReminder() {
</div>;
}
function IndependentPassword({authenticated}) {
function IndependentPassword({authenticated, hashParams}) {
// Only the genuine "haven't set a password yet, setting one now to enable youth mode for the
// first time" flow should auto-continue into enableYouthMode below -- see IdentityCheck's
// comment on the same param for why this can't just be inferred from enable_action_token being
// present.
const mode = hashParams && hashParams.ym;
const [pin, setPin] = React.useState('');
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
@@ -674,9 +709,44 @@ function IndependentPassword({authenticated}) {
if (!actionToken) { setError('身份验证已过期,请返回重新验证'); return; }
setBusy(true);
try {
await setIndependentPassword(pin, 'youth_mode', actionToken);
const result = await setIndependentPassword(pin, 'youth_mode', actionToken);
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
setError(''); setToast('独立密码设置成功'); setPin('');
setPin('');
// 设密码只是"首次开启青少年模式"这条路的中间一步,不是终点。服务端在这一步就把下一步
// 要用的凭证一起发回来了(enable_action_token + next_action:"enable_youth_mode",
// 见 server/im_http.go:6178-6189)。原来这里把它整个丢掉、只弹一句"设置成功"就停在
// PIN 页——用户走完短信验证+设密码,青少年模式其实还是关的,得自己摸回设置页再输一遍。
// 现在拿着这个 token 把最后一步做完。
// enable_action_token comes back unconditionally on every successful (re)set, regardless of
// why the caller ended up here (server doesn't know or care) -- only the genuine "first time
// enabling" flow should spend it. Otherwise this silently flipped youth mode on for someone
// who was mid-way through disabling it, or who came from the unrelated generic "身份验证"
// entry with no youth-mode intent at all.
const enableToken = result && result.enable_action_token;
if (enableToken && mode === 'enable') {
try {
const current = await getYouthMode();
await enableYouthMode(current.policy_version, enableToken);
setError(''); setToast('青少年模式已开启');
window.setTimeout(() => navigate('youth-mode-on'), 600);
return;
} catch (enableErr) {
// 密码确实设成功了,只是自动开启这一步没成,如实说明并把人送回青少年模式页手动开
setError('独立密码已设置,但自动开启未成功:' + (enableErr.message || '请返回重试'));
window.setTimeout(() => navigate('youth-mode-off'), 1200);
return;
}
}
if (mode === 'disable') {
// enableToken (if any) can't be spent here -- ConsumeActionToken requires an exact
// action='close_youth_mode' match, and this one is always tagged 'enable_youth_mode'. The
// user does know the fresh PIN now though, so send them straight back to actually finish
// disabling instead of leaving them stuck on this screen with nothing left to do.
setError(''); setToast('独立密码已重置,请使用新密码关闭青少年模式');
window.setTimeout(() => navigate('youth-mode-verify', {ym: 'disable'}), 800);
return;
}
setError(''); setToast('独立密码设置成功');
} catch (err) {
setError(err.message || '设置失败,请返回重新验证身份');
} finally {
@@ -697,7 +767,7 @@ function IndependentPassword({authenticated}) {
}
export default function AuthOnboardingView({
view, onTenantResolved, authenticated = false,
view, hashParams, onTenantResolved, authenticated = false,
login, loginDisabled, persist, errorText, errorLevel,
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
}) {
@@ -710,11 +780,11 @@ export default function AuthOnboardingView({
case 'enterprise-feedback': return <EnterpriseFeedback />;
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} hashParams={hashParams} />;
case 'realname': return <Realname authenticated={authenticated} />;
case 'realname-verified': return <Realname authenticated={authenticated} />;
case 'realname-reminder': return <RealnameReminder />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} hashParams={hashParams} />;
default: return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
}
}
+163 -25
View File
@@ -6,7 +6,8 @@ import HashNavigation from '../lib/navigation.js';
import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
blockUser, unblockUser, getFileUrl, getBootstrap
blockUser, unblockUser, getFileUrl, getBootstrap, getConversationContext, getConversationSettings,
updateConversationSettings, createUserQRCode, fetchAllPages
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js';
@@ -19,7 +20,7 @@ const navigate = (view, uid) => {
};
export const CONTACT_MANAGEMENT_VIEWS = [
'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-search-result',
'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result',
'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings',
'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm',
'clear-chat-confirm'
@@ -111,12 +112,14 @@ function AddressBook({myUserId, onTopicSelected}) {
const fileAssistantTopic = bootstrap && bootstrap.entries && bootstrap.entries.file_assistant
&& bootstrap.entries.file_assistant.topic;
React.useEffect(() => {
getFriends().then(data => {
const list = data.items || [];
// getFriends() defaults to a 50-item page -- a plain single call silently truncated address
// books past that size, with no "load more" affordance to recover the rest.
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
setFriends(list);
}).catch(() => {}).finally(() => setLoading(false));
getFriendRequests('inbound', 'pending').then(data => setPendingCount((data.items || []).length)).catch(() => {});
fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor))
.then(list => setPendingCount(list.length)).catch(() => {});
}, []);
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.includes(query));
const groups = visible.reduce((result, item) => {
@@ -148,7 +151,11 @@ function AddressBook({myUserId, onTopicSelected}) {
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">暂无好友</p> : null}
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark ? <small>{person.display_name}</small> : null}</span>
{/* 副行只在确实是另一个名字时才显示。后端在有备注时会把 display_name 也设成备注
(im_http.go:8500-8507,且合并守卫会挡住用 peer 真实昵称覆盖),两个字段同值,
原来那句 `person.remark ? <small>{display_name}</small>` 会把同一串字渲染两遍。
后端目前没有单独的原昵称字段,所以拿不到"备注(主)/昵称(副)"的第二行,已记为后端缺口。 */}
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark && person.remark !== person.display_name ? <small>{person.display_name}</small> : null}</span>
</button>)}
</section>)}
</main>
@@ -168,7 +175,8 @@ function FriendRequests({onToast}) {
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
getFriendRequests('all', 'all').then(data => setItems(data.items || [])).catch(() => {}).finally(() => setLoading(false));
fetchAllPages(cursor => getFriendRequests('all', 'all', cursor))
.then(setItems).catch(() => {}).finally(() => setLoading(false));
}, []);
const handle = (item, accept) => {
const nextStatus = accept ? 'accepted' : 'rejected';
@@ -191,6 +199,24 @@ function FriendRequests({onToast}) {
</main></div>;
}
// 中国大陆手机号:11 位、1 开头。与本文件注册/验证流程用的判断保持一致。
const CN_MOBILE = /^1[3-9]\d{9}$/;
function classifySearchInput(text) {
if (text.startsWith('+')) {
// 已经是国际格式,原样按手机号查
return {query: text.replace(/[\s-]/g, ''), type: 'phone'};
}
const digits = text.replace(/[\s-]/g, '');
if (CN_MOBILE.test(digits)) {
return {query: '+86' + digits, type: 'phone'};
}
return {query: text, type: 'exact_account'};
}
// 与 personal-settings-view.jsx 的 GENDER_OPTIONS 一一对应,两处写的是同一套后端枚举
const GENDER_LABELS = {male: '男', female: '女', other: '其他'};
// `GET /users/{id}/profile` (getUserProfile) 403s for anyone who isn't already a friend, by design
// (server's default `profile_visibility` is "friends"-only, server/im_http.go's imPrivacyAllowsProfile).
// But the two places that hand you a *stranger* to look at -- an account search hit and a scanned
@@ -215,22 +241,41 @@ function AddFriend({result = false, onToast, onOpenScanner}) {
const [searched, setSearched] = React.useState(result);
const [results, setResults] = React.useState([]);
const runSearch = q => {
if (!q.trim()) { setSearched(false); setResults([]); return; }
const text = q.trim();
if (!text) { setSearched(false); setResults([]); return; }
setSearching(true); setSearched(true);
searchUsers(q.trim(), 'exact_account').then(data => setResults(data.items || [])).catch(() => setResults([])).finally(() => setSearching(false));
// 按输入形态选搜索类型。exact_account 只比对 profile.public_account / u.username / u.id
// (tenant_adapter.go:9170-9172),**不匹配 u.phone** —— 参考图 5.5 里输的恰恰是手机号
// 18826827724,一直写死 exact_account 的话这个场景必然搜不到。手机号要走 type=phone。
// 另外 phone 分支服务端不做 E.164 归一化(只有模糊的 account 分支才调 normalizeUserPhone),
// 而库里存的是 +86 开头的 E.164,所以裸 11 位号码由前端补上区号——注册流程也是这么拼的。
const {query, type} = classifySearchInput(text);
searchUsers(query, type).then(data => setResults(data.items || [])).catch(() => setResults([])).finally(() => setSearching(false));
};
// 原来复制到剪贴板这条分支不给任何反馈,点了界面上毫无动静(同页的 QrPage.share 是有 toast 的);
// 生成二维码失败也被 .catch(() => {}) 静默吞掉,用户同样什么都看不到。
const shareCard = () => {
createMyQRCode().then(data => {
const text = `加我为好友:${data.qr_payload}`;
if (navigator.share) {
navigator.share({title: '添加好友', text}).catch(() => {});
} else if (navigator.clipboard) {
navigator.clipboard.writeText(text);
return navigator.share({title: '添加好友', text}).catch(() => {});
}
}).catch(() => {});
if (navigator.clipboard) {
return navigator.clipboard.writeText(text).then(() => onToast('邀请链接已复制'))
.catch(() => onToast('复制失败,请手动分享二维码'));
}
onToast('当前浏览器不支持分享,请使用二维码');
return undefined;
}).catch(err => onToast(err.message || '生成邀请链接失败'));
};
// Real search results carry a relationship for blocked pairs too (blocked_by_me/blocked_by_peer),
// which previously fell through to the same generic "添加" as an untouched stranger -- misleading
// since tapping through leads to a profile page whose own cannotAddReason (see the Profile
// component below) already refuses to let the request go anywhere. Matches that component's
// wording, just condensed to fit this one-line list item.
const actionLabel = item => item.relationship === 'friend' ? '已是好友' :
item.relationship === 'pending_outbound' ? '已申请' : item.relationship === 'pending_inbound' ? '待通过' : '添加';
item.relationship === 'pending_outbound' ? '已申请' : item.relationship === 'pending_inbound' ? '待通过' :
item.relationship === 'blocked_by_me' ? '已拉黑' : item.relationship === 'blocked_by_peer' ? '无法添加' : '添加';
return <div className="people-page"><Header title="添加好友" back="address-book" />
<Search value={query} onChange={v => { setQuery(v); if (!v.trim()) { setSearched(false); setResults([]); } }} placeholder="查找账号" onSubmit={() => runSearch(query)} />
<main>
@@ -278,6 +323,27 @@ function QrPage({onToast}) {
</main></div>;
}
function FriendQr({uid, onToast}) {
const [payload, setPayload] = React.useState(null);
const [profile, setProfile] = React.useState(null);
React.useEffect(() => {
if (!uid) return;
createUserQRCode(uid).then(data => { setPayload(data.qr_payload); setProfile(data.display_profile); }).catch(() => onToast('二维码生成失败'));
}, [uid]);
const save = () => {
const canvas = document.querySelector('.people-qrcode canvas');
if (!canvas) { onToast('二维码尚未生成'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = '好友二维码.png';
a.click();
onToast('二维码已保存到相册');
};
return <div className="people-page people-qr-page"><Header title="二维码" back="friend-profile" backUid={uid} /><main><QrCard payload={payload} profile={profile} loading={!payload} />
<div className="people-qr-actions"><button type="button" onClick={save}><Icon>download</Icon><span>保存到相册</span></button></div>
</main></div>;
}
function Verification({uid, onToast}) {
// Same seed-then-best-effort-refresh as Profile -- reached right after it (via its "添加好友"
// button), so the cache this reads is the one Profile itself just kept warm.
@@ -335,6 +401,32 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
return current;
});
});
// getUserProfile 403s outright for someone you've blocked (server-side privacy check) --
// whatever relationship was cached from before the block (typically "friend") then just stays
// stuck, which used to make this page keep showing "发消息/音视频通话" for someone actually
// blocked, entirely missing the blocked_by_me/blocked_by_peer branches cannotAddReason already
// handles below. getConversationContext resolves for a P2P conversation regardless of block
// status and correctly reports the real relationship, so it's used here purely to refresh that
// one field on top of whatever getUserProfile/the seed cache already provided for the rest.
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
}
}).catch(() => {});
}, [uid]);
// group-management-view.jsx 的 navigateToProfile 在跳转之后异步用 getGroupMemberProfile 把
// capabilities/relationship 补进种子缓存(那两个字段 getUserProfile 对非好友会 403 拿不到,
// 群场景权限更宽松能拿到)。这个组件已经挂载了,不会自己重新读缓存,靠这个事件通知它有更新。
React.useEffect(() => {
if (!uid) return undefined;
const onSeedUpdated = e => {
if (e.detail && e.detail.uid === uid) {
const updated = readCachedData('profile-seed:' + uid);
if (updated) setProfile(updated);
}
};
window.addEventListener('profile-seed-updated', onSeedUpdated);
return () => window.removeEventListener('profile-seed-updated', onSeedUpdated);
}, [uid]);
if (!profile) return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} /><main className="people-profile"><p className="people-empty-hint">{loadError ? '资料加载失败,请重试' : '加载中…'}</p></main></div>;
const canAdd = profile.capabilities && profile.capabilities.can_add_friend;
@@ -356,8 +448,16 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
<main className="people-profile">
<section><Avatar person={profile} /><div><h2>{profile.display_name}</h2>
{profile.public_account ? <p>用户名:{profile.public_account}</p> : null}
{profile.region ? <p>地区:{profile.region}</p> : null}</div><Icon>qr_code</Icon></section>
<dl>{profile.gender ? <div><dt>性别</dt><dd>{profile.gender}</dd></div> : null}
{profile.region ? <p>地区:{profile.region}</p> : null}</div></section>
{/* 参考图 好友个人主页.png 里"二维码"是独立一行(不是贴在头像卡片里的小图标),点击生成
这个好友的二维码名片。对应接口 createUserQRCode() 早就封装好了,原来这个图标没有任何
onClick,是个看着能点、点了没反应的死装饰。 */}
<button type="button" className="people-profile-qr-row" onClick={() => navigate('friend-qr', uid)}>
<span>二维码</span><Icon>qr_code</Icon>
</button>
{/* 后端存的是 male/female/other 枚举(personal-settings-view.jsx:203 的 GENDER_OPTIONS 写进去的),
原来直接原样输出,资料页上显示的是英文 "male"。 */}
<dl>{profile.gender ? <div><dt>性别</dt><dd>{GENDER_LABELS[profile.gender] || profile.gender}</dd></div> : null}
<div><dt>个性签名</dt><dd className={profile.signature ? '' : 'muted'}>{profile.signature || '此人很懒什么都没留下~'}</dd></div>
</dl>
{isFriend ? <div className="people-profile-actions">
@@ -370,14 +470,20 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
</div>;
}
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
function Toggle({checked, onChange, disabled}) { return <button type="button" role="switch" aria-checked={checked} disabled={disabled} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
function Settings({uid, initialBlocked, onToast}) {
function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
// real, current friend (see seedProfile's own comment), which used to leave this page's header
// blank instead of just stuck like Profile, but no less wrong.
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
const [blocked, setBlocked] = React.useState(initialBlocked);
// 置顶/免打扰原来是纯本地 useState,onChange 只是切一下布尔值,从不调用任何接口——
// GET/PATCH /conversations/{topic}/settings 早就封装好了(getConversationSettings/
// updateConversationSettings),只是零调用点。实测接口返回真实的 {pinned, mute_notifications,
// version},PATCH 需要带 version 做乐观锁。打开设置页永远显示"关",点了也只是那次会话内
// 生效、离开页面就丢,和会话列表真正读取的置顶/免打扰状态是两套毫不相关的东西。
const [settingsVersion, setSettingsVersion] = React.useState(null);
const [pinned, setPinned] = React.useState(false);
const [muted, setMuted] = React.useState(false);
const [editingRemark, setEditingRemark] = React.useState(false);
@@ -385,13 +491,30 @@ function Settings({uid, initialBlocked, onToast}) {
const [remarkDraft, setRemarkDraft] = React.useState('');
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {});
getConversationSettings(uid).then(s => {
setPinned(!!s.pinned); setMuted(!!s.mute_notifications); setSettingsVersion(s.version);
}).catch(() => {});
// pinBlocked:确认弹窗把这个页面当背景渲染,并用 initialBlocked 摆出"操作之后"的样子。
// 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来,
// 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗
// 正在确认的这件事自相矛盾。
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {
// Same 403-on-blocked-contact gap as Profile above -- this used to leave `blocked` stuck at
// whatever initialBlocked was (usually false, e.g. arriving fresh from the address book)
// whenever the block didn't just happen in this same page session, showing an off "拉黑"
// toggle and the "删除好友" button for someone actually already blocked.
if (!pinBlocked) {
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) { setBlocked(ctx.relationship === 'blocked_by_me'); }
}).catch(() => {});
}
});
// GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always
// calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 --
// folding an empty remark into display_name instead of returning a separate field), only the
// friends list endpoint's friendListItemPayload does. Look this friend up there instead.
getFriends().then(data => {
const entry = (data.items || []).find(item => item.user_id === uid);
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
const entry = list.find(item => item.user_id === uid);
if (entry) setRemark(entry.remark || '');
}).catch(() => {});
}, [uid]);
@@ -409,8 +532,20 @@ function Settings({uid, initialBlocked, onToast}) {
<span>查看聊天记录</span><Icon>chevron_right</Icon>
</button>
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
<section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div>
<div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div>
<section><div><span>置顶聊天</span><Toggle checked={pinned} disabled={settingsVersion === null} onChange={value => {
// settingsVersion starts null until getConversationSettings' GET resolves (below); a toggle
// clicked inside that window sent version:null over the wire, which the server 400s on as
// MALFORMED_REQUEST -- disabling the switch during that (usually brief, but real on a slow
// connection) gap avoids that avoidable round trip and its "设置失败" toast entirely.
const prev = pinned; setPinned(value);
updateConversationSettings(uid, {pinned: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
.catch(err => { setPinned(prev); onToast(err.message || '设置失败'); });
}} /></div>
<div><span>消息免打扰</span><Toggle checked={muted} disabled={settingsVersion === null} onChange={value => {
const prev = muted; setMuted(value);
updateConversationSettings(uid, {mute_notifications: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
.catch(err => { setMuted(prev); onToast(err.message || '设置失败'); });
}} /></div>
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
{editingRemark ?
<div className="people-remark-edit"><input value={remarkDraft} maxLength="20" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
@@ -437,7 +572,9 @@ function Settings({uid, initialBlocked, onToast}) {
function BlockList({onToast}) {
const [people, setPeople] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => { getBlocks().then(data => setPeople(data.items || [])).catch(() => {}).finally(() => setLoading(false)); }, []);
React.useEffect(() => {
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => {}).finally(() => setLoading(false));
}, []);
const remove = userId => {
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
.catch(err => onToast(err.message || '操作失败'));
@@ -475,7 +612,7 @@ function ConfirmSheet({type, uid, tinode, onToast}) {
call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); })
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
};
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} onToast={onToast} />
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} pinBlocked={true} onToast={onToast} />
<div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>{config.action}</button>
<button type="button" onClick={() => navigate('friend-settings', uid)}>取消</button>
@@ -491,6 +628,7 @@ export default function ContactManagementView({view, hashParams, myUserId, tinod
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} onToast={setToast} onOpenScanner={onOpenScanner} />;
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
else if (view === 'friend-qr') content = <FriendQr uid={uid} onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
+130 -37
View File
@@ -8,9 +8,9 @@ import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
import {
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, getGroup, getUserProfile
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile
} from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
import { topicDisplayName } from '../lib/utils.js';
// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a
@@ -39,7 +39,7 @@ const navigate = (view, params) => {
export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
'file-downloading', 'file-downloaded', 'history-search', 'history-files',
'history-links', 'history-images', 'history-videos'
];
@@ -62,11 +62,23 @@ function Search({value, onChange, placeholder = '搜索'}) { return <label class
function draftyContentOf(content) {
return Drafty.isValid(content) ? content : null;
}
// 必须用 Drafty.entities 而不是 Drafty.attachments:后者在 SDK 里硬过滤 `ent.tp == 'EX'`
// (tinode.dev.js:2538),只认「文件附件」这一种实体。而本应用发图片/视频/语音走的分别是
// insertImage(IM)/insertVideo(VD)/appendAudio(AU),在 attachments 下一个都取不到——收藏的图片视频
// 缩略图、语音收藏的播放源、聊天记录的图片视频宫格因此全部拿不到资源。entities 的回调第三参给出
// tp,可以按类型分派,chat-message.jsx:139 用的就是这个。
const MEDIA_ENTITY_TYPES = ['IM', 'VD', 'AU', 'EX'];
function firstAttachment(content) {
const drafty = draftyContentOf(content);
if (!drafty) return null;
let found = null;
Drafty.attachments(drafty, att => { if (!found) found = att; });
Drafty.entities(drafty, (data, _index, tp) => {
if (!found && data && MEDIA_ENTITY_TYPES.includes(tp)) {
found = data;
return true; // 命中第一个就停,entities 的回调返回 true 表示中断遍历
}
return false;
});
return found;
}
// Drafty.getDownloadUrl returns either an already-usable blob: URL (small inband attachment, .val) or
@@ -114,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>;
@@ -127,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}
@@ -145,23 +170,33 @@ 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}
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime} 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}
{/* 需求 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} 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>;
}
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'};
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links', image: 'favorites-image', video: 'favorites-video'};
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
function Favorites({view, tinode, onToast}) {
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : view === 'favorites-image' ? 'image' : view === 'favorites-video' ? 'video' : 'all';
const selecting = view === 'favorites-select';
const [type, setType] = React.useState(selecting ? 'file' : initialType);
const [query, setQuery] = React.useState('');
// 输入值与实际发请求用的值分开:原来 query 直接进 load 的依赖数组,输入 6 个字的关键词就打
// 6 次接口,而且返回顺序没有保证——后发的短前缀请求可能后到、把正确结果覆盖掉。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
const [selected, setSelected] = React.useState([]);
// Seeded per-type -- one Favorites instance backs every favorites* tab/route (confirmed multiple
// entry points: personal-settings' "我的收藏" row, direct-message-view.jsx's attachment-panel
@@ -218,7 +253,7 @@ function Favorites({view, tinode, onToast}) {
}).catch(err => onToast(err.message || '删除失败'));
};
const openItem = item => {
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id}); return; }
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id, fromType: type}); return; }
if (item.type === 'link') {
const url = (item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text);
checkURL(url, 'favorite').then(res => {
@@ -227,13 +262,21 @@ function Favorites({view, tinode, onToast}) {
}).catch(() => onToast('链接安全校验失败'));
return;
}
if (item.type === 'image' || item.type === 'video') {
// item._thumbUrl is the real resolveAttachmentUrl() result (see the effect above) -- this
// backend has no separate scaled-down thumbnail concept, so the same URL used to render the
// grid cell is already the full attachment, safe to open directly.
if (item._thumbUrl) window.open(item._thumbUrl, '_blank', 'noopener,noreferrer');
else onToast('正在加载,请稍后重试');
return;
}
onToast('暂不支持预览此类收藏');
};
const list = items || [];
return <div className="library-page">
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'}
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
<Search value={query} onChange={setQuery} placeholder="搜索收藏内容" />
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索收藏内容" />
<nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''}
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
<main className="library-favorite-list">
@@ -270,9 +313,18 @@ function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
if (sending) return;
setSending(true);
forwardFavorites(ids, selected, comment.trim()).then(result => {
// `items` is one row per (favorite x target) pair once a target passes the permission check,
// or one bare row per target when it fails outright -- either way its length isn't `selected.length`
// (target count) whenever more than one favorite is being shared at once, so a plain
// `selected.length - failedItems` mixed the two units and could go negative. Group back to the
// "会话" granularity the toast actually claims to report: a target only counts as fully sent once
// every one of its own items succeeded.
const items = (result && result.items) || [];
const failedCount = items.filter(r => r.status !== 'sent').length;
onToast(failedCount ? `${selected.length - failedCount}/${selected.length} 个会话分享成功` : `已分享给 ${selected.length} 个会话`);
const successTargets = selected.filter(target => {
const forTarget = items.filter(r => r.target_topic === target);
return forTarget.length > 0 && forTarget.every(r => r.status === 'sent');
}).length;
onToast(successTargets === selected.length ? `已分享给 ${selected.length} 个会话` : `${successTargets}/${selected.length} 个会话分享成功`);
window.setTimeout(() => navigate('favorites-files'), 700);
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
};
@@ -311,18 +363,35 @@ function DownloadPage({hashParams, tinode, onToast}) {
const [state, setState] = React.useState('ready');
const [progress, setProgress] = React.useState(0);
const downloaderRef = React.useRef(null);
const {favoriteId, topic, seq} = hashParams || {};
const {favoriteId, topic, seq, fromType} = hashParams || {};
// Two distinct entry points share this route: Favorites (favoriteId set, Ch.7.1) and History's
// file tab (topic+seq set, Ch.7.12) -- "back" must return to whichever one actually opened this
// page, not unconditionally to favorites-files (which History never came from and has no topic
// param for anyway).
const backView = favoriteId ? 'favorites-files' : 'history-files';
// param for anyway). Within Favorites itself, `openItem` now also threads through the tab the user
// was actually on (`fromType`) -- opening a file from "全部" or "文件" both land here, and should
// each return to where they came from, not always to the "文件" tab regardless of origin.
const backView = favoriteId ? (TYPE_ROUTE[fromType] || 'favorites-files') : 'history-files';
const backParams = favoriteId ? undefined : {topic};
React.useEffect(() => {
const apply = content => {
const att = firstAttachment(content);
if (!att) { onToast('文件信息加载失败'); setLoadError(true); return; }
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url: Drafty.getDownloadUrl(att)});
// Drafty.getDownloadUrl's raw return value is only actually usable as-is for a small inband
// attachment (an already-local blob: URL built from inline base64 data). For an out-of-band
// one -- everything above the ~3MB inline threshold, i.e. essentially every real photo/video/
// document -- it's the bare server-relative .ref path (e.g. "/v0/file/s/xxx"), which the
// browser would resolve against *this page's own* origin rather than the tenant's real
// (often cross-origin) file domain. resolveAttachmentUrl() elsewhere in this file already
// gets this right by routing ref-backed attachments through getFileUrl() -- but that eagerly
// fetches the whole file into a blob immediately, which is wrong here: this page hasn't had
// the user press "立即下载" yet, and doing that on mount would silently pull the entire file in
// the background and make the ready/downloading/done states below meaningless.
// resolveFileUrl() is the same domain-qualification with no fetch, exactly what's needed to
// just populate `meta.url` for later use by the real download button.
const direct = Drafty.getDownloadUrl(att);
const url = (direct && (direct.startsWith('blob:') || direct.startsWith('data:'))) ?
direct : resolveFileUrl(direct && direct.slice(direct.lastIndexOf('/') + 1));
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url});
};
if (favoriteId) {
getFavorite(favoriteId).then(fav => apply(fav.snapshot && fav.snapshot.content)).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
@@ -388,14 +457,29 @@ function DownloadPage({hashParams, tinode, onToast}) {
</div>;
}
function Highlight({text, ranges}) {
if (!ranges || !ranges.length) return text;
const parts = []; let last = 0;
ranges.forEach(([start, len], i) => {
parts.push(text.slice(last, start), <mark key={i}>{text.slice(start, start + len)}</mark>);
last = start + len;
});
parts.push(text.slice(last));
// 不用服务端返回的 matches 偏移,改为在展示文本上本地匹配关键词。服务端那份偏移有三层问题,
// 全都对不上前端要高亮的这段文字:
// 1. 结构是 [{start,end}] 对象数组(im_http.go:9151),按 [start,len] 数组解构会直接抛
// TypeError(对象不可迭代),整个搜索结果渲染崩掉;
// 2. start/end 是 Go 的字节下标(strings.Index + len(query)),中文一字三字节,跟 JS 的
// UTF-16 下标永远错位;
// 3. 偏移基准是完整原文,但 snippet 在超过 120 字时会被开窗截断(im_http.go:9128-9139),
// 两者基准不一致——而且那段开窗自己也混用了字节下标和 rune 切片。
// 本地匹配没有这些歧义:所见即所高亮。
function Highlight({text, query}) {
const needle = (query || '').trim();
if (!needle || !text) return text;
const parts = [];
const haystack = text.toLowerCase();
const lowered = needle.toLowerCase();
let last = 0;
for (let at = haystack.indexOf(lowered); at !== -1; at = haystack.indexOf(lowered, at + lowered.length)) {
if (at > last) parts.push(text.slice(last, at));
parts.push(<mark key={at}>{text.slice(at, at + needle.length)}</mark>);
last = at + needle.length;
}
if (!parts.length) return text;
if (last < text.length) parts.push(text.slice(last));
return parts;
}
@@ -418,7 +502,10 @@ function History({view, hashParams, myUserId, onToast}) {
// records (or checks a different chat's history later the same session), so dropping either half
// of the key would bleed one chat's or one tab's data into another's.
const cacheKey = topic ? `history:${topic}:${view}` : null;
const [query, setQuery] = React.useState('');
// 输入值与实际发请求用的值分开:原来 query 直接进 load 的依赖数组,输入 6 个字的关键词就打
// 6 次接口,而且返回顺序没有保证——后发的短前缀请求可能后到、把正确结果覆盖掉。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
const [items, setItems] = React.useState(() => (cacheKey && readCachedData(cacheKey)) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
@@ -486,7 +573,7 @@ function History({view, hashParams, myUserId, onToast}) {
<Header title={HISTORY_TITLES[view]}
back={topic && topic.startsWith('grp') ? 'group-settings' : 'friend-settings'}
backParams={topic && topic.startsWith('grp') ? {topic} : {uid: topic}} />
{view === 'history-search' ? <Search value={query} onChange={setQuery} placeholder="搜索当前会话" /> : null}
{view === 'history-search' ? <Search value={queryInput} onChange={setQueryInput} placeholder="搜索当前会话" /> : null}
<HistoryNav active={view} />
<main className="library-history-content">
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
@@ -494,7 +581,7 @@ function History({view, hashParams, myUserId, onToast}) {
view === 'history-search' ? <section className="library-message-results">
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
<Highlight text={item.snippet || ''} ranges={item.matches} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
<Highlight text={item.snippet || ''} query={query} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
</button>)}
</section> :
@@ -507,11 +594,17 @@ function History({view, hashParams, myUserId, onToast}) {
</React.Fragment>;
})}</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} />
{/* 需求7.14"点击时执行外链安全策略,并允许用户定位回原始消息"是两个独立动作——点链接本身
走 openLink(安全校验+开外链),定位回原消息复用其它tab都在用的 jump(seq),挂在头部
这一行上(sender+时间),跟 history-files 的头部保持视觉一致,只是这里多了可点击行为。 */}
<button type="button" className="library-record-header" onClick={() => jump(item.seq)}>
<strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small>
</button>
<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>;
+328 -83
View File
@@ -5,13 +5,14 @@ import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import {
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
updateMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
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
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
getGroupMemberProfile, fetchAllPages
} from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
@@ -24,14 +25,46 @@ const navigate = (view, topic) => {
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
// viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it
// can't go through this file's own `navigate` helper above without clobbering the wrong hash param.
const navigateToProfile = userId => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
// 除了跳转,还必须先把已知资料写进 'profile-seed:' 缓存 —— 目标页(第5章的 Profile)会先读这个种子,
// 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里
// 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口
// (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。
const navigateToProfile = (topic, userId, member) => {
// 先用群成员列表里已有的字段摆一个立即可用的种子(避免导航瞬间空白),
// 但这份种子缺 capabilities/relationship——GET /groups/{topic}/members 从不返回这两个字段
// (已实测确认),Profile 组件正是靠它们判断"要不要显示添加好友按钮"和"该显示哪句失败文案"。
// 缺了它们,非好友的群成员主页会落到 cannotAddReason 的默认分支——不管真实关系是什么,
// 一律显示"好友申请正在等待验证",添加好友按钮永远不出现。
if (member && userId) {
writeCachedData('profile-seed:' + userId, {
user_id: userId,
display_name: member.group_nickname || member.display_name || userId,
avatar_file_id: member.avatar_file_id,
...(member.signature ? {signature: member.signature} : {}),
...(member.region ? {region: member.region} : {}),
...(member.gender ? {gender: member.gender} : {})
});
}
HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
// 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更
// 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的
// GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还
// 停在这个人的资料页(没有连点跳到别处),用真实数据覆盖种子并强制该页面重新读取。
if (topic && userId) {
getGroupMemberProfile(topic, userId).then(real => {
const current = readCachedData('profile-seed:' + userId) || {};
writeCachedData('profile-seed:' + userId, {...current, ...real});
window.dispatchEvent(new CustomEvent('profile-seed-updated', {detail: {uid: userId}}));
}).catch(() => {});
}
};
export const GROUP_MANAGEMENT_VIEWS = [
'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests',
'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail',
'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description',
'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
'group-file-upload'
@@ -60,6 +93,27 @@ function groupBy(list, keyFn) {
return Object.keys(groups).sort().map(key => [key, groups[key]]);
}
// GET /v1/im/groups 的 name/member_count 目前不可信:handler 用的是 store.Users.GetSubs,而 store 自己的
// 注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),可 groupListItemPayload
// 偏偏去读 sub.GetPublic() 取名字、sub.GetSubCnt() 取人数——于是每个群都返回自己的 topic id 当名字、
// 0 当人数(应该用 GetTopics,它会把 Public 拷到 subscription 上)。已记入后端问题清单。
// 在后端修好之前,用本地 Tinode 会话缓存兜底——消息首页的群名一直是从这里取的,是准的。
function groupDisplayName(tinode, group) {
if (group.name && group.name !== group.topic) return group.name;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
const pub = t && t.public;
// 群的 public 用的是 `name`(实测本地缓存就是 {name:"扫码测试群", version:1}),后端自己的
// groupName() 也是按 name/display_name/fn 这个顺序找的,保持一致。
const local = pub && (pub.name || pub.display_name || pub.fn);
return local || group.name || group.topic;
}
function groupMemberCount(tinode, group) {
if (group.member_count) return group.member_count;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
return (t && t.subcnt) || 0;
}
function shortDate(iso) {
if (!iso) return '';
const d = new Date(iso);
@@ -82,9 +136,11 @@ const GROUPS_DEMO = {
};
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'group-list', backTopic, right, onRight}) {
// onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1],
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
return <header className="group-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backTopic); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
@@ -118,13 +174,16 @@ function Toggle({checked, onChange, disabled}) {
className={'group-switch' + (checked ? ' checked' : '')} onClick={() => !disabled && onChange(!checked)}><i /></button>;
}
function GroupList({onToast}) {
function GroupList({tinode, onToast}) {
// Only the unfiltered (query === '') result is worth caching -- caching a search result under the
// same key would show stale search results instead of the real list on the next plain visit.
const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null});
const [loadErrors, setLoadErrors] = React.useState({owned: false, managed: false, joined: false});
const [category, setCategory] = React.useState('owned');
const [query, setQuery] = React.useState('');
// 这里比别处更值得防抖:reload 对 owned/managed/joined 三个分类各发一次请求,
// 每敲一个字符就是 3 个 getGroups。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
const [creating, setCreating] = React.useState(false);
const [name, setName] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
@@ -151,7 +210,7 @@ function GroupList({onToast}) {
};
return <div className="group-page">
<Header title="群聊" back="address-book" right="创建" onRight={() => setCreating(true)} />
<Search value={query} onChange={setQuery} placeholder="搜索群聊" />
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索群聊" />
<nav className="group-tabs">
{[['owned', '我创建的'], ['managed', '我管理的'], ['joined', '我加入的']].map(([key, label]) =>
<button type="button" key={key} className={category === key ? 'active' : ''} onClick={() => setCategory(key)}>
@@ -163,7 +222,7 @@ function GroupList({onToast}) {
current.length ? current.map(group =>
<button type="button" key={group.topic} onClick={() => navigate('group-settings', group.topic)}>
<Avatar item={group} />
<span><strong>{group.name}</strong><small>{group.member_count} 位成员</small></span>
<span><strong>{groupDisplayName(tinode, group)}</strong><small>{groupMemberCount(tinode, group)} 位成员</small></span>
<Icon>chevron_right</Icon>
</button>) :
<div className="group-empty"><Icon>groups</Icon><strong>这里还没有群聊</strong>
@@ -173,7 +232,12 @@ function GroupList({onToast}) {
</main>
{creating ? <div className="group-modal-backdrop"><section className="group-modal">
<h2>创建群聊</h2>
<label>群名称<input autoFocus value={name} maxLength="30" onChange={event => setName(event.target.value)} placeholder="输入群名称" /></label>
{/* 参考图 设置昵称(1).png 明确把这同一个字段(group.name)的上限画成 11 字(0/11)——
这里原来是 30,跟编辑页的 11 对不上:建群时能起最多 30 字的名字,之后进编辑页
却连打字都打不过 11 位,改不回原来的长度。后端本身没有强制 11(今天实测 PATCH 17 字
成功),11 纯粹是这一份参考图给的前端约束,创建页应该和它保持一致,而不是反过来放宽
编辑页——市面上没有任何参考图支持 30 这个数字。 */}
<label>群名称<input autoFocus value={name} maxLength="11" onChange={event => setName(event.target.value)} placeholder="输入群名称" /></label>
<p>创建后可继续邀请成员和设置群资料。</p>
<footer>
<button type="button" onClick={() => setCreating(false)}>取消</button>
@@ -193,11 +257,17 @@ function GroupChat({mode, onToast}) {
return <div className="group-page"><ChatHeader />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS_DEMO.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>;
}
function JoinRequests({topic, onToast}) {
function JoinRequests({topic, fromChat, onToast}) {
// A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests)
// is a second entry point besides group-settings, and this whole feature remounts on every
// navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working
// through requests re-triggers this blank "加载中" flash on essentially every accept/reject.
// `fromChat`/`backToChat` mirror Announcement's handling of the exact same two-entry-point
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
// "group-settings" (closing the open conversation) instead of back to the chat itself.
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
const [profiles, setProfiles] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
@@ -234,7 +304,8 @@ function JoinRequests({topic, onToast}) {
// to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong.
const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'};
return <div className="group-page">
<Header title="邀请进群申请" back="group-settings" backTopic={topic} />
<Header title="邀请进群申请" back={fromChat ? undefined : 'group-governance'} backTopic={topic}
onBack={fromChat ? backToChat : undefined} />
<main className="group-request-list">
{items === null ? <p className="group-inline-empty">{loadError ? '申请列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item =>
@@ -268,7 +339,13 @@ function Announcement({topic, myRole, fromChat, onToast}) {
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
const backView = fromChat ? '' : 'group-settings';
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
// 这里显式把 topic 写回 path[1]。
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
React.useEffect(() => {
if (!topic) return;
const cached = readCachedData('group-announcement:' + topic);
@@ -286,9 +363,19 @@ function Announcement({topic, myRole, fromChat, onToast}) {
setData(d); setEditing(false); onToast('群公告已发布');
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
};
if (!data) return <div className="group-page"><Header title="群公告" back={backView} backTopic={topic} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
// 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={backView} backTopic={topic}
<Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined}
right={canEdit ? '发布' : null} onRight={canEdit ? publish : undefined} />
<main className="group-announcement-detail">
{canEdit ? <textarea autoFocus={editing} value={draft} maxLength={2000}
@@ -368,13 +455,23 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
<span><strong>{group.name}</strong><small>{group.description || '群主很懒,还没有群简介'}</small></span>
<Icon>chevron_right</Icon>
</button>
<section className="group-member-strip">
<div>{members.map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(item.user_id)}>
<Avatar item={item} /><small>{item.group_nickname || item.display_name}</small>
</button>)}</div>
<button type="button" aria-label="邀请成员" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}><Icon>person_add_alt</Icon></button>
{/* 参考图 docs/images/群聊设置.png:"群成员"标题行右侧是可点的"查看{N}群成员 >",现在真的有
落地页了(GroupMembers,复用 MemberPicker 同款字母分组/搜索,只读不可多选)。参考图里最后
一格的"删除"(移除成员)没有做——那是一个独立的、需要产品先确认交互细节的成员管理功能,
不属于这条"看不到全部成员"缺口本身。 */}
<section className="group-member-section">
<button type="button" className="group-member-section-title" onClick={() => navigate('group-members', topic)}>
<strong>群成员</strong><small>共 {group.member_count} 位成员</small><Icon>chevron_right</Icon>
</button>
<div className="group-member-grid">
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
</button>)}
<button type="button" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}>
<span className="group-member-tile-action"><Icon>add</Icon></span><small>添加</small>
</button>
</div>
</section>
<p className="group-inline-hint">共 {group.member_count} 位成员</p>
<section>{links.map(item => <button type="button" key={item[0]} onClick={() => navigate(item[0], topic)}>
<span>{item[1]}</span><small>{item[2]}</small><Icon>chevron_right</Icon>
</button>)}</section>
@@ -386,46 +483,73 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
const t = tinode ? tinode.getTopic(topic) : null;
(t ? t.delMessagesAll(false) : Promise.resolve()).then(() => onToast('聊天记录已清除')).catch(err => onToast(err.message || '操作失败'));
}}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => onToast('举报入口暂未开放')}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
只会写 topic,所以直接拼 hash。 */}
<button type="button" onClick={() => {
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
HashNavigation.navigateTo(hash);
}}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
</section>
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
</main>
</div>;
}
function GroupProfile({topic, onToast}) {
function GroupProfile({topic, myUserId, onToast}) {
// Shares 'group:'+topic with the nickname/description editors' own getGroup(topic) calls -- opening
// this once warms the cache for either of those too.
const [group, setGroup] = React.useState(() => (topic && readCachedData('group:' + topic)) || null);
const [loadError, setLoadError] = React.useState(false);
// "群昵称" here means "my nickname within this group" (see MyNicknameEditor below), not the group's
// own name -- confirmed live this preview row was showing group.name instead, since it's easy to
// conflate the two. GET .../members/me/profile is the only endpoint that actually returns this --
// confirmed live that the member list endpoint (getGroupMembers) never includes group_nickname on
// any item, so a version of this fix that read it from there (like MyNicknameEditor below does)
// would always show "未设置" even right after a successful save.
const [myNickname, setMyNickname] = React.useState('');
React.useEffect(() => {
if (!topic) return;
setGroup(readCachedData('group:' + topic) || null);
getGroup(topic).then(g => { writeCachedData('group:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
React.useEffect(() => {
if (!topic) return;
getMyGroupProfile(topic).then(data => {
setMyNickname(data.nickname || '');
}).catch(() => {});
}, [topic]);
if (!group) return <div className="group-page"><Header title="群资料" back="group-settings" backTopic={topic} /><main className="group-profile"><p className="group-inline-empty">{loadError ? '群组信息加载失败' : '加载中…'}</p></main></div>;
const canEdit = group.permissions && group.permissions.can_edit_profile;
const nicknameLabel = myNickname || '未设置';
return <div className="group-page">
<Header title="群资料" back="group-settings" backTopic={topic} />
<main className="group-profile">
<button type="button" className="group-profile-avatar" disabled={!canEdit} onClick={() => canEdit && navigate('group-avatar', topic)}>
<Avatar item={group} /><span>群头像</span><Icon>chevron_right</Icon>
</button>
<button type="button" onClick={() => navigate('group-my-nickname', topic)}>
<span>群昵称</span><small>{nicknameLabel}</small><Icon>chevron_right</Icon>
</button>
{canEdit ? <button type="button" onClick={() => navigate('group-nickname', topic)}>
<span>群昵称</span><small>{group.name}</small><Icon>chevron_right</Icon>
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
</button> :
<div><span>群昵称</span><small>{group.name}</small></div>}
<div><span>群名称</span><small>{group.name}</small></div>}
{canEdit ? <button type="button" onClick={() => navigate('group-description', topic)}>
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
</button> :
<div><span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small></div>}
<button type="button" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
<p>{group.member_count} 位成员</p>
<button type="button" className="group-profile-qr" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => navigate('group-members', topic)}><span>群成员</span><small>共{group.member_count}位成员</small><Icon>chevron_right</Icon></button>
</main>
</div>;
}
function AvatarSheet({topic, tinode, onToast}) {
function AvatarSheet({topic, myUserId, tinode, onToast}) {
const inputRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const pick = () => inputRef.current && inputRef.current.click();
@@ -526,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 = () => {
@@ -541,7 +670,9 @@ function Governance({topic, tinode, onToast}) {
return <div className="group-page">
<Header title="群组管理" back="group-settings" backTopic={topic} />
<main className="group-governance">
<button type="button" onClick={() => navigate('group-avatar', topic)}><span>设置群头像</span><Icon>chevron_right</Icon></button>
<section>
<button type="button" onClick={() => navigate('group-avatar', topic)}><span>设置群头像</span><Icon>chevron_right</Icon></button>
</section>
<section>
<button type="button" onClick={() => navigate('group-join-requests', topic)}><span>邀请进群申请</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => navigate('group-muted', topic)}><span>禁言的群成员</span><Icon>chevron_right</Icon></button>
@@ -577,8 +708,9 @@ function AdminList({topic, onToast}) {
// Fetch the unfiltered member list rather than {role:'admin'} + a separate per-user profile lookup
// for the owner -- GET /users/{id}/profile 400s ("malformed request", field "user_id") when {id} is
// the caller's own uid, which broke this whenever the viewer is themselves the group's owner.
getGroupMembers(topic).then(data => {
const items = data.items || [];
// Members cap out at 30/page against a 128-member group limit -- an admin sitting past page 1
// used to vanish from this list entirely rather than the list just being incomplete.
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
writeCachedData('group-members:' + topic, items);
const d = deriveOwnerAndAdmins(items);
setOwner(d.owner);
@@ -594,11 +726,11 @@ function AdminList({topic, onToast}) {
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
<main className="group-admin-page">
<aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 10 名管理员。</p></aside>
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.display_name}</strong><small>群主</small></span></article></> : null}
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.group_nickname || owner.display_name}</strong><small>群主</small></span></article></> : null}
<h2>管理员 {admins ? admins.length : 0}/10</h2>
{admins === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
admins.length ? admins.map(item => <article key={item.user_id}>
<Avatar item={item} /><span><strong>{item.display_name}</strong><small>管理员</small></span>
<Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong><small>管理员</small></span>
<button type="button" onClick={() => remove(item.user_id)}>移除</button>
</article>) : <div className="group-inline-empty">暂无管理员</div>}
</main>
@@ -606,6 +738,37 @@ function AdminList({topic, onToast}) {
</div>;
}
// 参考图 群聊设置.png 里"群成员"标题行是可点的("查看{N}群成员 >"),但 GroupSettings 一直没有
// 对应的落地页——群成员超过预览网格的9人就完全看不到其余成员。复用 MemberPicker 已经在用的
// 字母分组+搜索逻辑,去掉多选/提交,只读展示每个人的角色。
function GroupMembers({topic, onToast}) {
const [members, setMembers] = React.useState(() => (topic && readCachedData('group-members:' + topic)) || null);
const [query, setQuery] = React.useState('');
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
writeCachedData('group-members:' + topic, items);
setMembers(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return <div className="group-page">
<Header title="群成员" back="group-settings" backTopic={topic} />
<Search value={query} onChange={setQuery} placeholder="搜索群成员" />
<main className="group-member-picker">
{members === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : groups.map(([letter, list]) => <React.Fragment key={letter}>
<div className="group-section-title">{letter}</div>
{list.map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><span><strong>{item.group_nickname || item.display_name}</strong>{roleLabel(item.role) ? <small>{roleLabel(item.role)}</small> : null}</span>
</button>)}
</React.Fragment>)}
</main>
</div>;
}
// Shared alphabetically-grouped multi-select picker for admin-add / mute-add (same visual component,
// three purposes, per docs/images/添加管理员.png vs .../添加管理员-设置成功(1).png which turned out to
// actually be the mute-add variant of the identical layout).
@@ -620,8 +783,8 @@ function MemberPicker({type, topic, onToast}) {
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, mute ? {muteEligible: true} : {eligibleRole: 'admin'})
.then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
fetchAllPages(cursor => getGroupMembers(topic, {cursor, ...(mute ? {muteEligible: true} : {eligibleRole: 'admin'})}))
.then(items => setMembers(items.filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, mute]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
@@ -632,9 +795,22 @@ function MemberPicker({type, topic, onToast}) {
setBusy(true);
const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected);
call.then(result => {
const failed = result && result.succeeded ? result.succeeded.filter(s => !s.ok) : [];
setToast(failed.length ? `成功设置 ${selected.length - failed.length} 人,${failed.length} 人设置失败` : '设置成功');
window.setTimeout(() => navigate(back, topic), 900);
// 两个批量接口的响应结构不一样,不能用同一套判定:
// - 添加管理员 POST .../admins/batch 返回 succeeded[],每项带 ok/reason(im_http.go:5377-5392)
// - 添加禁言 POST .../mutes 返回 {topic, items},只有成功的才进 items,
// 非法 uid 会被静默 continue(im_http.go:5486-5488),根本没有 succeeded 字段
// 原来两条路都读 succeeded,禁言那条恒为 undefined → failed 恒为空 → 不管实际成没成都弹"设置成功"。
let failedCount;
if (mute) {
const done = (result && Array.isArray(result.items)) ? result.items.length : selected.length;
failedCount = Math.max(selected.length - done, 0);
} else {
failedCount = (result && Array.isArray(result.succeeded)) ? result.succeeded.filter(s => !s.ok).length : 0;
}
setToast(failedCount ? `成功设置 ${selected.length - failedCount} 人,${failedCount} 人设置失败` : '设置成功');
// 导航会让整棵子树重挂载、把 toast 一起销毁。原来 900ms 就跳走,而 CenterToast 的生命
// 周期是 1400ms —— 参考图里那个「设置成功」实际上只闪一下就被截断,而且从不在目标列表页出现。
window.setTimeout(() => navigate(back, topic), 1500);
}).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));
};
return <div className="group-page">
@@ -664,18 +840,23 @@ function Muted({topic, onToast}) {
// everyone still in the group, and fetching it here is one extra request for the whole list
// (not per-row), unlike JoinRequests' per-item getUserProfile lookup.
const [names, setNames] = React.useState({});
const [avatars, setAvatars] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
getGroupMutes(topic).then(data => {
const list = data.items || [];
fetchAllPages(cursor => getGroupMutes(topic, cursor)).then(list => {
writeCachedData('group-muted:' + topic, list);
setItems(list);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
const map = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
const avatarMap = {};
items.forEach(m => {
map[m.user_id] = m.group_nickname || m.display_name || m.user_id;
if (m.avatar_file_id) avatarMap[m.user_id] = {avatar_file_id: m.avatar_file_id};
});
setNames(map);
setAvatars(avatarMap);
}).catch(() => {});
}, [topic]);
React.useEffect(() => { setItems((topic && readCachedData('group-muted:' + topic)) || null); load(); }, [topic, load]);
@@ -688,9 +869,15 @@ function Muted({topic, onToast}) {
<main className="group-muted-list">
{items === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item => <article key={item.user_id}>
<Avatar item={{}} /><span><strong>{names[item.user_id] || item.user_id}</strong><small>{item.expires_at ? `禁言至 ${shortDate(item.expires_at)}` : '永久禁言'}</small></span>
{/* 必须传 icon="person":Avatar 的兜底图标默认是 'group',禁言的是人不是群,
原来每一行都画成群组图标。getGroupMutes 本身不返回 avatar_file_id
(im_http.go:7083-7096),但同组件已经为了解析显示名拉过整份成员表,顺带把头像也带上。 */}
<Avatar item={avatars[item.user_id] || {}} icon="person" /><span><strong>{names[item.user_id] || item.user_id}</strong><small>{item.expires_at ? `禁言至 ${shortDate(item.expires_at)}` : '永久禁言'}</small></span>
<button type="button" onClick={() => unmute(item.user_id)}>解除</button>
</article>) : <div className="group-inline-empty">以上成员不允许发言</div>}
</article>) : <div className="group-inline-empty">暂无禁言成员</div>}
{/* "以上成员不允许发言"在参考图里是列表下方的常驻灰色注脚,不是空状态文案。
原来把它当空态用,结果有禁言成员时这句话消失、一个都没有时反而显示它,语义正好反了。 */}
{items && items.length ? <p className="group-inline-note">以上成员不允许发言</p> : null}
</main>
</div>;
}
@@ -699,20 +886,36 @@ function OwnerSelect({topic, onToast}) {
const [members, setMembers] = React.useState(null);
const [query, setQuery] = React.useState('');
const [target, setTarget] = React.useState(null);
// 后端 serveIMGroupOwnerTransfer 只认 {target_user_id, version},没有任何二次验证/action_token
// 机制(跟青少年模式关闭那种真的会被服务端拒绝的场景不一样)。群主转让同样不可逆、后果同样
// 重大,照抄本项目已有的处理这类"后端没有二次验证但操作本身很危险"场景的方式——见
// personal-settings-view.jsx 的 DeleteAccountConfirm:加一步前端自己的密码输入门槛,不追加到
// 请求体、不指望服务端校验它,只是让"手滑点确定"变成需要多一步真实操作才会发生的事。
const [passwordStep, setPasswordStep] = React.useState(false);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, {eligibleRole: 'owner'}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
fetchAllPages(cursor => getGroupMembers(topic, {cursor, eligibleRole: 'owner'}))
.then(items => setMembers(items.filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
const confirm = () => {
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)}>
@@ -727,11 +930,19 @@ function OwnerSelect({topic, onToast}) {
{others.length ? <><div className="group-section-title">群成员</div>{others.map(row)}</> : null}
</>}
</main>
{target ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
{target && !passwordStep ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>选择新群主</h2>
<p>{target.group_nickname || target.display_name}成为新群主,确定后你将立即失去群主身份</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>确定</button>
<button type="button" onClick={() => setTarget(null)}>取消</button>
<button type="button" className="danger" onClick={() => setPasswordStep(true)}>确定</button>
<button type="button" onClick={reset}>取消</button>
</section></div> : null}
{target && passwordStep ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>输入密码确认转让</h2>
<p>请输入登录密码以确认将群主转让给{target.group_nickname || target.display_name}(该操作立即生效,不可撤销)。</p>
<input type="password" autoFocus className="group-password-input" value={password}
onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
<button type="button" className="danger" disabled={busy} onClick={confirm}>{busy ? '处理中…' : '确定'}</button>
<button type="button" onClick={reset}>取消</button>
</section></div> : null}
</div>;
}
@@ -742,23 +953,45 @@ const FILE_TYPE_META = {
};
function GroupFiles({topic, tinode, onToast}) {
const [query, setQuery] = React.useState('');
// 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 [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [uploading, setUploading] = React.useState(null);
const [loadError, setLoadError] = React.useState(false);
// groupFilePayload 只给 uploader_id、不给昵称(im_http.go:6532-6559),原来直接把裸 user_id
// 显示在"来自"位置。跟 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;
getGroupMembers(topic).then(data => {
const map = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
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 => {
@@ -786,19 +1019,22 @@ function GroupFiles({topic, tinode, onToast}) {
<button type="button" onClick={() => pick('image')}><Icon>image</Icon>上传照片</button>
<button type="button" onClick={() => pick('video')}><Icon>play_circle_outline</Icon>上传视频</button>
</div> : null}
<Search value={query} onChange={setQuery} placeholder="搜索文件名或者上传者" />
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB/10GB)</p>
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索文件名或者上传者" />
{/* "/10GB" 分母曾经是前端编的——群文件相关接口(含治理/设置)全仓库没有任何配额字段下发,
显示一个无依据的上限数字比不显示更容易误导人,改成只报告已用量。 */}
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB)</p>
<main className="group-file-list">
{uploading ? <article className="uploading">
<span className="group-file-type" style={{backgroundColor: '#8a8f98'}}><Icon>upload_file</Icon></span>
<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>
<div><strong>{file.file_name || '未命名文件'}</strong><small>{bytesToGB(file.file_size)} · {file.uploader_id}</small></div>
<div><strong>{file.file_name || '未命名文件'}</strong><small>来自 {uploaderNames[file.uploader_id] || file.uploader_id} · {bytesToGB(file.file_size)}</small></div>
<button type="button" onClick={() => remove(file.group_file_id)}><Icon>delete_outline</Icon></button>
</article>;
})}
@@ -806,12 +1042,17 @@ function GroupFiles({topic, tinode, onToast}) {
</div>;
}
// 名字叫 bytesToGB 却只输出到 MB —— 1.4GB 的文件会显示成 "1433.6 MB"。补上 GB 档。
function bytesToGB(bytes) {
if (!bytes) return '0 KB';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// 标题"群昵称"是参考图 设置昵称(1).png 本身的用词(不是笔误)——虽然编辑的其实是 group.name
// 这个所有人都看得到的真实群名,跟 MyNicknameEditor("我在本群的昵称",个人在群内的显示名,
// 完全不同的字段)概念上容易混,但这是设计稿明确画出来的文案,不是代码猜的,不要改。
function GroupNameEditor({topic, onToast}) {
const [group, setGroup] = React.useState(null);
const [value, setValue] = React.useState('');
@@ -840,8 +1081,10 @@ function GroupDescriptionEditor({topic, onToast}) {
return <div className="group-page">
<Header title="群简介" back="group-profile" backTopic={topic} right="发布" onRight={publish} />
<main className="group-edit-text">
<textarea autoFocus value={value} maxLength={225} placeholder="请输入群简介内容" onChange={e => setValue(e.target.value)} />
<small>{value.length}/225</small>
{/* 上限跟后端硬限对齐(im_http.go:4667 超过 200 rune 直接 400 field=description)。
参考图标的是 0/225,但那个数字后端不接受——按 225 放行会让用户写满再被服务端打回。 */}
<textarea autoFocus value={value} maxLength={200} placeholder="请输入群简介内容" onChange={e => setValue(e.target.value)} />
<small>{value.length}/200</small>
</main>
</div>;
}
@@ -849,15 +1092,14 @@ function GroupDescriptionEditor({topic, onToast}) {
// "我在本群的昵称" has no dedicated reference screenshot (see plan notes) -- placed in GroupSettings,
// governed by the real member_invite... no, by the group_nickname field on groupMemberPayload, gated by
// the "群成员修改昵称" concept the images described (server-side enforcement, not re-checked client-side).
function MyNicknameEditor({topic, myUserId, onToast}) {
function MyNicknameEditor({topic, onToast}) {
const [value, setValue] = React.useState('');
// getGroupMembers' list items never include group_nickname (confirmed live) -- only
// GET .../members/me/profile actually returns it, same fix as GroupProfile's preview row above.
React.useEffect(() => {
if (!topic || !myUserId) return;
getGroupMembers(topic, {query: ''}).then(data => {
const me = (data.items || []).find(m => m.user_id === myUserId);
if (me) setValue(me.group_nickname || '');
}).catch(() => {});
}, [topic, myUserId]);
if (!topic) return;
getMyGroupProfile(topic).then(data => setValue(data.nickname || '')).catch(() => {});
}, [topic]);
const save = () => {
updateMyGroupProfile(topic, value.trim(), 0).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);
@@ -882,26 +1124,29 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
if (!topic || !myUserId) { setMyRole(null); return; }
const key = `group-my-role:${topic}:${myUserId}`;
setMyRole(readCachedData(key) || null);
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
const me = items.find(m => m.user_id === myUserId);
const role = me ? me.role : null;
// Used to look this up by paginating the full member list and finding myUserId in it (30/page
// against a 128-member cap) -- a real owner/admin sitting past page 1 read back as `null`,
// silently hiding every governance affordance gated on this value. getGroup's own `my_role`
// (already relied on elsewhere, e.g. the disband-group guard above) isn't paginated at all --
// it's always exactly "me", so this no longer needs the member list at all.
getGroup(topic).then(group => {
const role = group ? group.my_role : null;
writeCachedData(key, role);
setMyRole(role);
}).catch(() => {});
}, [topic, myUserId]);
let content;
if (view === 'group-list' || view === 'group-empty') content = <GroupList onToast={setToast} />;
if (view === 'group-list' || view === 'group-empty') content = <GroupList tinode={tinode} onToast={setToast} />;
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} onToast={setToast} />;
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-settings') content = <GroupSettings topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'group-profile') content = <GroupProfile topic={topic} onToast={setToast} />;
else if (view === 'group-avatar') content = <AvatarSheet topic={topic} tinode={tinode} onToast={setToast} />;
else if (view === 'group-profile') content = <GroupProfile topic={topic} myUserId={myUserId} onToast={setToast} />;
else if (view === 'group-avatar') content = <AvatarSheet topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'group-nickname') content = <GroupNameEditor topic={topic} onToast={setToast} />;
else if (view === 'group-description') content = <GroupDescriptionEditor topic={topic} onToast={setToast} />;
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
else if (view === 'group-members') content = <GroupMembers topic={topic} onToast={setToast} />;
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
else if (view === 'group-governance') content = <Governance topic={topic} tinode={tinode} onToast={setToast} />;
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList topic={topic} onToast={setToast} />;
+6 -13
View File
@@ -1,25 +1,18 @@
/* This is just a static page to display when no conversation is selected. */
/* Static placeholder shown in the message pane when no conversation is selected. */
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Tinode } from 'tinode-sdk';
import { BASE_APP_NAME } from '../config.js';
import { APP_NAME } from '../config.js';
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
export default class LogoView extends React.PureComponent {
render() {
const version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
return (
<div id="dummy-view">
<div>
<a href="https://github.com/tinode/chat/">
<img id="logo" alt="logo" src="img/logo.svg" />
<h2>Tinode Web</h2>
</a>
<p><FormattedMessage id="label_client" defaultMessage="Client:"
description="Label for a client version" /> {version}</p>
<p><FormattedMessage id="label_server" defaultMessage="Server:"
description="Label for a server version" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
<span id="dummy-view-icon"><Icon>forum</Icon></span>
<h2>{BASE_APP_NAME}</h2>
<p>选择一个会话开始聊天</p>
</div>
</div>
);
+156 -10
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js';
import { getBootstrap, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { useCachedData } from '../lib/use-cached-data.js';
import { topicDisplayName } from '../lib/utils.js';
@@ -10,7 +10,14 @@ import { topicDisplayName } from '../lib/utils.js';
// Must go through setUrlSidePanel (not a bare navigateTo(view), which replaces the whole hash) so
// switching panels from "messages-home" doesn't silently drop path[1] -- the currently open
// conversation topic, which coexists with this panel in the split view once a chat is selected.
const navigate = view => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, view));
// `topic` 可选:系统消息里的群事件通知需要带着群 topic 跳进对应群聊设置页,写法与
// group-management-view.jsx 的同名辅助函数保持一致(不带 topic 时显式清掉,避免把上一个页面
// 残留的 topic 参数带进不相关的页面)。
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
};
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
@@ -98,12 +105,23 @@ function formatConversationTime(ts) {
const now = new Date();
const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000);
if (days === 0) return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
if (days === 1) return '昨天';
const hhmm = () => date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
if (days === 0) return hhmm();
// 参考图 docs/images/消息.png 里昨天的会话显示的是"昨天21:03",带具体时间,不是只有"昨天"
if (days === 1) return '昨天' + hhmm();
if (days < 7) return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];
return date.toLocaleDateString('zh-CN', {month: 'numeric', day: 'numeric'});
}
// 参考图 系统消息.png 的时间格式是"6月28日 10:04"——不带年份、中文月日、不带秒。原来直接用
// toLocaleString('zh-CN') 默认格式,会输出"2026/8/20 18:04:00"(带年份、斜杠、秒),跟设计不符。
function formatNotificationTime(ts) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${d.getMonth() + 1}月${d.getDate()}日 ${hh}:${mm}`;
}
// Entries (system messages / file assistant) come from bootstrap.entries and are always shown --
// unlike the old mock, they don't disappear once the user has real conversations (that was a bug).
function entryConversations(bootstrap) {
@@ -131,15 +149,33 @@ function normalizeConversations(chatList, myUserId) {
let summary = item.private && item.private.comment ? item.private.comment : '暂无消息';
if (latest) summary = draftyPreview(latest.content);
if (latest && latest.from === myUserId) summary = '我:' + summary;
const isGroup = typeof item.topic === 'string' && item.topic.startsWith('grp');
return {
id: item.topic || item.user || String(index),
title: topicDisplayName(item.topic, item.public, '未命名会话'),
summary,
time: formatConversationTime(item.touched),
unread: Math.max(Number(item.unread) || 0, 0),
online: !!item.online,
// 真实头像:跟同文件 frequentContacts() 一样从 public.photo 取,原来漏了这一项,
// 导致会话列表每一行都只能落到下面的兜底图标,永远显示不出头像。
avatar: makeImageUrl(item.public && item.public.photo),
// 没有真实头像时的兜底图标要区分群聊和单聊,否则群聊也画成单人像,列表里分不出哪条是群
icon: isGroup ? 'group' : 'person',
// 在线状态只对单聊有意义:群不存在"在线",传 undefined 让 Avatar 不渲染那个状态点
online: isGroup ? undefined : !!item.online,
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
pinned: !!item.pinned
// 原来读的是 item.pinned——这个键在原生订阅对象上根本不存在,永远 undefined,置顶开关点了
// 会话列表从来没有过任何视觉变化。真实存放位置是 subscription 原生 private.pinned 字段,
// 但只对**群聊**成立:group-management-view.jsx 的置顶走 updateGroupMemberSettings ->
// serveIMGroupMemberSettings,服务端确实写的是 Subs.Private,随原生 {meta} 同步下来(真实
// 验证:开关点亮后要等下一次重连/刷新才会在这里体现——同一会话内没有实时 {pres} 通知去让
// 已连接的这份 subscription 缓存失效,这是已知的、这次没有顺手解决的次要延迟,不是没生效)。
// contact-management-view.jsx 的单聊置顶走的是完全不同的 updateConversationSettings ->
// serveIMConversationSettings,服务端存进独立的 im_conversation_settings 表,从不写入原生
// Subs.Private——单聊置顶因此不管等多久重连都不会在这里出现,这是真实的后端架构缺口(没有
// 批量查询会话设置的接口,前端也不可能为渲染一屏会话列表挨个按 topic 发请求),已记录到
// 后端问题清单,不是这一行能修的。
pinned: !!(item.private && item.private.pinned)
};
});
}
@@ -168,6 +204,30 @@ function QuickMenu({onClose, onAction, permissions}) {
</div>;
}
// 1.10 实名认证提醒:docs/images/实名认证(安卓).png 显示的是不可跳过的强制态(单按钮"去实名
// 认证",没有关闭/稍后处理)——但那张图对应的是 bootstrap.security.realname.required===true 这个
// 分支,这个环境(以及目前后端唯一会返回的默认配置)实际是 required:false / allow_skip:true。
// 照抄参考图做成完全关不掉的弹层,等于把"实名认证目前是可选的"这个真实配置,实现成挡住所有人的
// 强制墙——所以按这两个字段分叉:允许跳过时加一个参考图没画但后端明确允许的关闭按钮;且遵守
// remind_after,跳过后在这个时长内不重复弹(不是每次进消息首页都弹一次)。
function parseRemindAfter(text) {
const m = /^(\d+)\s*([hdm])$/i.exec(String(text || '').trim());
if (!m) return 24 * 60 * 60 * 1000;
const n = Number(m[1]);
return m[2].toLowerCase() === 'd' ? n * 86400000 : m[2].toLowerCase() === 'h' ? n * 3600000 : n * 60000;
}
function RealnameReminderModal({allowSkip, onDismiss}) {
return <div className="auth-modal-backdrop" onClick={allowSkip ? onDismiss : undefined}>
<div className="auth-modal" onClick={e => e.stopPropagation()}>
{allowSkip ? <button type="button" className="auth-modal-close" aria-label="关闭" onClick={onDismiss}><Icon>close</Icon></button> : null}
<h2>实名认证提醒</h2>
<p>根据国家法律法规要求,您需先进行实名认证才能使用该项 APP</p>
<button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button>
</div>
</div>;
}
function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOpenScanner}) {
const [query, setQuery] = React.useState('');
const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu');
@@ -177,10 +237,48 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
const conversations = entryConversations(bootstrap).concat(normalizeConversations(chatList, myUserId));
// "置顶" only ever changed a background tint (see the `pinned` comment inside
// normalizeConversations) -- every other IM this product's users have used floats a pinned chat to
// the top of the list and keeps it there, so a tint-only "pin" reads as broken even where the
// underlying flag is genuinely correct (group chats). Array.prototype.sort is stable (guaranteed
// since ES2019), so this only reorders pinned-vs-not and otherwise preserves chatList's own
// ordering. Applied after normalizing, not to the concatenated list, so the two fixed system
// entries (system_messages/file_assistant) -- which have no `pinned` concept at all -- stay
// pinned to the very top the way they always have, ahead of even a pinned real conversation.
const realConversations = normalizeConversations(chatList, myUserId)
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
const conversations = entryConversations(bootstrap).concat(realConversations);
const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
const frequent = frequentContacts(chatList);
// bootstrap.security.realname carries required/allow_skip/remind_after, but -- confirmed live --
// its handler (serveIMBootstrap) builds that block from a 4-field literal that never includes
// submission_id, unlike the dedicated GET /security/realname endpoint (realNamePayload). The
// dismissKey fix below depends on submission_id to tell two separate rejections apart; sourcing
// `status` from bootstrap alone made that fix a no-op in practice (it always fell back to `status`,
// reproducing the exact bug it was meant to fix). Fetching this separately, the same
// useCachedData-with-retry way bootstrap itself is fetched, gets the real submission_id.
const realnameMeta = bootstrap && bootstrap.security && bootstrap.security.realname;
const [realnameStatus] = useCachedData(myUserId ? 'realname-status:' + myUserId : null, getRealnameStatus, [myUserId]);
const [reminderDismissed, setReminderDismissed] = React.useState(false);
// Keyed by submission_id (a new one is minted per resubmission, server/im_http.go's
// realNamePayload/serveIMRealNameSubmissions), not just status -- two separate rejections both
// report status:"rejected", and without something that actually changes between them, dismissing
// the first would keep suppressing the reminder for a brand new rejection submitted and rejected
// again within the same snooze window. Falls back to status for the states that never had a
// submission (e.g. never-submitted "unverified", where there's only ever one such state anyway).
const dismissKey = (myUserId && realnameStatus) ?
`realname-reminder-dismissed:${myUserId}:${realnameStatus.submission_id || realnameStatus.status}` : null;
let lastDismissedAt = 0;
try { lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0; } catch (_) { /* ignore */ }
const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realnameMeta && realnameMeta.remind_after);
const showRealnameReminder = !!realnameStatus && !reminderDismissed && !withinSnooze &&
(realnameStatus.status === 'unverified' || realnameStatus.status === 'rejected');
const dismissRealnameReminder = () => {
if (dismissKey) { try { localStorage.setItem(dismissKey, String(Date.now())); } catch (_) { /* ignore */ } }
setReminderDismissed(true);
};
const selectConversation = item => {
if (item.system) {
navigate('system-messages');
@@ -233,6 +331,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
<BottomNavigation />
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
<Toast message={toast} onDone={() => setToast('')} />
{showRealnameReminder ? <RealnameReminderModal allowSkip={!!(realnameMeta && realnameMeta.allow_skip)} onDismiss={dismissRealnameReminder} /> : null}
</div>;
}
@@ -281,6 +380,39 @@ function MessageActions() {
</div>;
}
// 系统通知的 summary 在多处后端代码里被直接填成机器码而不是人话:群事件统一写
// summary=event.EventType(server/im_group_events.go:42-44,所有群事件共用 title="群聊动态"),
// 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
const NOTIFICATION_TEXT = {
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
'group.mute.updated': '群禁言设置有变更',
'group.owner.transferred': '群主已转让',
'group.role.updated': '群成员角色有变更',
'group.settings.updated': '群设置有更新',
accepted: '对方已通过你的好友申请',
rejected: '对方拒绝了你的好友申请',
// 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量
// "continue"/"cancel"(风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码——
// 两个 decision 枚举值只有一个"continue"曾被收录,"cancel"(第11章发消息前风险提示点"取消发送"
// 产生)此前会直接把英文单词"cancel"展示在系统消息列表里。
continue: '你确认继续了一次安全风险提示',
cancel: '你取消了一次风险操作确认'
};
function systemNotificationText(item) {
const summary = (item.summary || '').trim();
// 只在查表命中时才替换。后端的 summary 有些确实是机器码(好友申请通过时是 "accepted"),
// 但也有些就是真实数据——日报提醒的 summary 是纯日期字符串
// (db/mysql/tenant_adapter.go:4917 传 date.Format("2006-01-02")),会议通知的 summary 是会议标题。
// 一旦按"看起来像机器码就吞掉"来判断,这些真实内容会被换成一句无意义的兜底文案。
const mapped = NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type];
if (mapped) return mapped;
return summary || '您有一条新的系统通知。';
}
function SystemMessages({authenticated}) {
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
@@ -310,12 +442,26 @@ function SystemMessages({authenticated}) {
{error ? <p className="messages-empty"><Icon>error_outline</Icon>{error}</p> : null}
{!loading && !error && items.length === 0 ? <p className="messages-empty"><Icon>notifications_none</Icon>暂无系统消息</p> : null}
{items.map(item => <article key={item.id} className={item.read_at ? 'read' : ''} onClick={() => markRead(item)}>
<time>{new Date(item.created_at).toLocaleString('zh-CN')}</time>
<time>{formatNotificationTime(item.created_at)}</time>
<div className="system-notification-card">
<header><h2>{item.title}</h2>{!item.read_at ? <span>新</span> : null}</header>
<p>{item.summary}</p>
<p>{systemNotificationText(item)}</p>
{item.action && item.action.device ? <dl><div><dt>设备:</dt><dd>{item.action.device}</dd></div>{item.action.ip ? <div><dt>IP地址:</dt><dd>{item.action.ip}</dd></div> : null}</dl> : null}
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button>
{/* 入口按通知自身的 action 决定,不再对所有通知一律显示"前往安全设置"。注意 action.topic
不能直接当成群:好友申请类通知的 action 里带的是 p2p 会话 topic
(db/mysql/tenant_adapter.go:8643),只有 grp 前缀才是真的群。都不匹配时不显示按钮,
避免把人导到跟这条通知无关的页面。
真实好友申请通知的 action 判断改用 event_type 而不是 action.type:实测
GET /system-notifications 拿到的 friend.request.created 通知,action 里只有
{user_id},从来没有 type 字段(当初写 action.type==='friend_request' 是凭对接文档
猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */}
{item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('group-settings', item.action.topic); }}>查看群聊 <Icon>chevron_right</Icon></button> :
item.event_type && item.event_type.startsWith('friend.request') ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('friend-requests'); }}>查看新朋友 <Icon>chevron_right</Icon></button> :
item.action && (item.action.device || item.action.ip) ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button> :
null}
<footer>保持警惕<br />即时通讯安全团队</footer>
</div>
</article>)}
+313 -28
View File
@@ -30,7 +30,8 @@ import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constan
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
getFavorites, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -42,6 +43,12 @@ const SHOW_GO_TO_LAST_DIST = 100;
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',
@@ -133,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,
@@ -150,6 +162,8 @@ class MessagesView extends React.Component {
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);
@@ -183,6 +197,11 @@ class MessagesView extends React.Component {
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);
@@ -275,6 +294,18 @@ class MessagesView extends React.Component {
}
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);
}
@@ -344,8 +375,11 @@ class MessagesView extends React.Component {
topic.onAuxUpdated = this.handleAuxUpdate;
}
this.mentionedUsers = [];
this.mentionAllPending = false;
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
this.loadDraft(topic);
}
if (topic) {
@@ -407,7 +441,9 @@ class MessagesView extends React.Component {
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0
pendingJoinRequests: 0,
draftText: null,
draftVersion: 0
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -434,6 +470,8 @@ class MessagesView extends React.Component {
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
@@ -1019,6 +1057,16 @@ class MessagesView extends React.Component {
}
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
@@ -1035,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.
@@ -1133,6 +1187,118 @@ class MessagesView extends React.Component {
}).catch(_ => {});
}
// 4.2-4.3 @提醒:只对群话题开放(私聊提@没有意义,也没有可选的"其他成员")。返回给
// send-message.jsx 的选择器渲染,真正的 uid 解析在 mentionSelected 里记下来,供发送时
// applyMentionResolution 使用。
mentionSearch(query) {
const topic = this.props.tinode ? this.props.tinode.getTopic(this.state.topic) : null;
if (!topic || !topic.isGroupType()) {
return Promise.resolve([]);
}
return getGroupMembers(this.state.topic, {mentionable: true, query}).then(data => {
const items = data.items || [];
// "所有人" (参考图 @人员—选择提醒人.png 里排在真实成员最前面的合成选项) -- server/topic.go's
// validateAndNormalizeMentions rejects head.mention_all from anyone who isn't admin
// (ErrPermissionDenied), so this is only offered to accounts GET .../members already told us
// can actually use it -- an ineligible account would otherwise see the option, pick it, and
// have the server silently reject the send.
const canMentionAll = !!(data.permissions && data.permissions.can_mention_all);
if (canMentionAll && '所有人'.includes((query || '').trim())) {
return [{user_id: MENTION_ALL_UID, display_name: '所有人'}, ...items];
}
return items;
});
}
mentionSelected(uid, name) {
if (uid === MENTION_ALL_UID) {
// Not a real Drafty mention to resolve -- sendMessage below reads this flag directly to set
// head.mention_all, independent of the this.mentionedUsers/applyMentionResolution machinery
// single-person @提醒 uses. The "@所有人" text itself still gets auto-detected by Drafty.parse
// into an ordinary (uid-less) MN entity and renders highlighted like any other mention --
// that's just decorative here since the real "notify everyone" effect comes from the head flag.
this.mentionAllPending = true;
return;
}
this.mentionedUsers = (this.mentionedUsers || []).filter(m => m.name !== name);
this.mentionedUsers.push({uid, name});
}
// 4.1 跨话题草稿:话题一切换就去问服务端这个会话有没有存过草稿。原生话题名(P2P 用对方
// user_id)直接可用,接口内部自己会做订阅存在性校验,不需要额外归一化。
loadDraft(topic) {
if (!topic) {
return;
}
const topicName = topic.name;
getTopicDraft(topicName).then(data => {
if (this.state.topic != topicName) {
return;
}
this.setState({draftText: data.content || '', draftVersion: data.version || 0});
}).catch(_ => {});
}
// 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的
// 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下
// SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。
//
// 定时器按话题分开存(this.draftSaveTimers 是个 map),不能像原来那样用单个共享句柄——
// 那样话题A打字触发的600ms保存,会在300ms后切到话题B打字时被 clearTimeout 直接取消掉,
// A刚打的字就再也不会发给服务器了。
//
// version 在这里(调用的当下,this.state.topic 必然还是 topicName,因为 onMessageChange
// 只有当前激活话题的 SendMessage 实例会触发)同步捕获进闭包,不能等定时器触发时才去读
// this.state.draftVersion——那时用户很可能已经切到别的话题,届时重新读到的要么是无关话题
// 的版本号,要么在判断不匹配时退化成硬编码的 0。后端 PUT .../draft 对 version=0 是"无条件
// 跳过乐观锁校验直接覆盖"而不是"仅当草稿真的不存在时才创建"(见
// docs/IM-后端问题清单-2026-09-04.md),这个 0 一旦被真的发出去,可能无声覆盖掉这段时间里
// 别的标签页/设备写入的更新草稿。
handleComposerChange(text) {
if (this.state.reply && this.state.reply.editing) {
return;
}
if (this.props.forwardMessage) {
return;
}
const topicName = this.state.topic;
if (!topicName) {
return;
}
const version = this.state.draftVersion || 0;
this.draftSaveTimers = this.draftSaveTimers || {};
if (this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
}
this.draftSaveTimers[topicName] = window.setTimeout(() => {
delete this.draftSaveTimers[topicName];
updateTopicDraft(topicName, text, [], version).then(d => {
if (this.state.topic == topicName) {
this.setState({draftVersion: d.version});
}
}).catch(_ => {});
}, 600);
}
// 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。
// 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实
// 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。
// knownVersion 由调用方(sendMessage)在发送发起的那一刻同步捕获传入,原因同上——避免在
// 发送成功回调触发时(用户很可能已经切换话题)重新派生出不可靠的版本号。
clearDraft(topicName, knownVersion) {
if (this.draftSaveTimers && this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
delete this.draftSaveTimers[topicName];
}
const version = knownVersion != null ? knownVersion :
(this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
updateTopicDraft(topicName, '', [], version).then(d => {
if (this.state.topic == topicName) {
this.setState({draftText: '', draftVersion: d.version});
}
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
@@ -1150,8 +1316,11 @@ class MessagesView extends React.Component {
handleShowJoinRequests(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic));
// `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx)
// return to this chat instead of always landing on group-governance.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
}
handleSendFriendVerification(e) {
@@ -1177,7 +1346,17 @@ class MessagesView extends React.Component {
if (!topic || !this.props.selectedSeqs.length) {
return;
}
topic.delMessagesList(this.props.selectedSeqs.slice(), true).then(_ => {
// 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');
@@ -1191,15 +1370,28 @@ class MessagesView extends React.Component {
return;
}
const topicName = this.state.topic;
favoriteMessages(this.props.selectedSeqs.map(seq => ({topic: topicName, seq}))).then(result => {
const failed = (result && result.failed) || [];
if (failed.length && failed.length === this.props.selectedSeqs.length) {
this.props.onError('收藏失败', 'err');
} else if (failed.length) {
this.showToast(`已收藏,${failed.length} 条失败`);
} else {
this.showToast('收藏成功');
}
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');
});
@@ -1228,6 +1420,25 @@ class MessagesView extends React.Component {
// 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
@@ -1255,16 +1466,57 @@ class MessagesView extends React.Component {
head = {reply: '' + this.state.reply.seq};
if (typeof msg == 'string') {
msg = Drafty.parse(msg);
this.applyMentionResolution(msg);
}
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
}
this.handleCancelReply();
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但
// data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val =>
// ({val: val.slice(1)})),不是 uid——那是纯文本解析器,不可能凭空知道 uid。真正的 uid
// 只在用户从选择器里点选时才知道,记在 this.mentionedUsers 里;这里按显示名回填,把
// 自动识别出的占位符换成真的 uid。没有任何选过的提醒时完全不碰 msg,维持字符串原样,
// 交给 SDK 自己在更底层 parse(跟这次改动之前完全一样的行为,不引入任何差异)。
const parsed = Drafty.parse(msg);
this.applyMentionResolution(parsed);
msg = parsed;
}
// Was only cleared on topic switch -- picking someone from the @提醒 picker once left them
// resolvable for every later message in the same topic too, so literal text "@张三" typed by
// hand, pasted, or quoted from someone else in a LATER, unrelated message would silently turn
// into a real mention of whoever was last picked under that name. A retry of a failed send
// (retrySend) reuses the already-resolved Drafty content stored on the failed pub, not this
// array, so clearing it here doesn't affect retries.
this.mentionedUsers = [];
if (this.mentionAllPending) {
// server/topic.go's validateAndNormalizeMentions reads head.mention_all directly -- it's a
// separate signal from the Drafty MN entity the "@所有人" text itself auto-parses into (that
// entity has no real uid and is purely decorative here, same as any other unresolved mention).
// Reset immediately after reading, same lifecycle as this.mentionedUsers just above -- a
// "所有人" pick should only apply to the one message being sent right now, not linger into
// later ones in the same topic.
head = {...(head || {}), mention_all: true};
this.mentionAllPending = false;
}
if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint};
}
const doSend = () => this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
const topicForDraft = this.state.topic;
// Captured now, not re-read inside the .then() below -- by the time send actually resolves
// the user may have switched topics, and clearDraft falling back to a hardcoded 0 runs into
// the same backend version=0-skips-the-lock issue explained in handleComposerChange above.
const draftVersionAtSend = this.state.draftVersion || 0;
const doSend = () => {
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
result.then(() => this.clearDraft(topicForDraft, draftVersionAtSend));
}
return result;
};
// Pre-send URL risk check (POST /security/urls/check) -- the only Ch.11 "anti-fraud" mechanism
// with a real backend behind it. Only plain-text composed messages are checked (not forwards,
@@ -1272,9 +1524,16 @@ class MessagesView extends React.Component {
// 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.
const urlMatch = originalText && originalText.match(/https?:\/\/\S+/);
if (urlMatch) {
return checkURL(urlMatch[0], 'message_compose').then(result => {
// Was missing the `g` flag -- .match() without it only ever returns the FIRST url in the message,
// so a second url (e.g. the phishing link in "看这个 https://safe.example.com 但是别信这个
// https://phish.example.com/login?token=abc") never got checked at all as long as the first one
// came back `allow`. Check every distinct url in the message and act on the riskiest verdict.
const urlMatches = originalText && originalText.match(/https?:\/\/\S+/g);
if (urlMatches) {
const RISK_RANK = {block: 2, warn: 1, allow: 0};
const urls = Array.from(new Set(urlMatches));
return Promise.all(urls.map(url => checkURL(url, 'message_compose'))).then(results => {
const result = results.reduce((worst, r) => (RISK_RANK[r.decision] || 0) > (RISK_RANK[worst.decision] || 0) ? r : worst);
if (result.decision === 'allow') {
return doSend();
}
@@ -1749,7 +2008,13 @@ class MessagesView extends React.Component {
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');
}
}
}
@@ -1780,7 +2045,15 @@ class MessagesView extends React.Component {
}
isDragEnabled() {
return this.state.isWriter && !this.state.unconfirmed && !this.props.forwardMessage && !this.state.peerMessagingDisabled;
// 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) {
@@ -1854,9 +2127,7 @@ class MessagesView extends React.Component {
let component;
if (!this.state.topic) {
component = <LogoView
serverVersion={this.props.serverVersion}
serverAddress={this.props.serverAddress} />;
component = <LogoView />;
} else {
let component2;
@@ -1997,6 +2268,7 @@ class MessagesView extends React.Component {
messageNodes.push(
<MetaMessage
deleted={true}
deletedText={this.props.intl.formatMessage(messages.message_deleted)}
key={msg.seq} />
);
} else {
@@ -2085,6 +2357,13 @@ class MessagesView extends React.Component {
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 = (
<>
@@ -2179,14 +2458,20 @@ class MessagesView extends React.Component {
// 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}
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 ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImageOrVideo}
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
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} />}
+94 -22
View File
@@ -3,7 +3,7 @@ import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME } from '../config.js';
import { BASE_APP_NAME, LINK_CONTACT_US } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
@@ -315,6 +315,11 @@ function SettingsList({onToast, onLogout}) {
<MenuRow icon="child_care" title="青少年模式" value={youthEnabled == null ? '…' : (youthEnabled ? '已开启' : '未开启')}
route={youthEnabled ? 'youth-mode-on' : 'youth-mode-off'} />
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
{/* security-support-view.jsx 的 FraudAwareness 组件自己的注释就写着这应该是个
"always-reachable safety-info page",但此前没有任何页面真的链接过去——只能靠手改
hash 才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
用的是同一套 hash 导航,可以直接指过去。 */}
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
</section>
<section><MenuRow icon="no_accounts" title="注销账号" route="delete-account-confirm" danger /></section>
<button type="button" className="personal-logout" onClick={logout}>退出当前登录</button>
@@ -341,12 +346,23 @@ function CacheClear({onToast}) {
const confirm = async () => {
setBusy(true);
try {
const before = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
const after = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;
// navigator.storage/caches can be entirely absent (private browsing in some browsers, older
// WebViews) rather than just rejecting -- referencing a missing global throws synchronously,
// which a bare .catch() on the call below can't catch (it never gets that far), so without
// this check the whole confirm() throws, `done` never becomes true, and the "已完成" state is
// never reached even though the button correctly stops spinning via `finally`.
const hasStorageEstimate = typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate;
const hasCaches = typeof caches !== 'undefined';
const before = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
if (hasCaches) {
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
}
const after = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
setFreed(Math.max(0, before - after));
setDone(true);
} catch (err) {
onToast(err.message || '清理失败,请重试');
} finally {
setBusy(false);
}
@@ -379,23 +395,34 @@ function ClearHistoryConfirm({tinode, chatList, onToast}) {
// (Tinode.updateAccountBasic), already wired end-to-end via tinode-web.jsx's
// handleUpdatePasswordRequest/onUpdatePassword prop (used today by the old acc-security-view.jsx).
// Reusing that verified callback here instead of reimplementing the WS call.
// The server-side account-update call never checks the caller's current password -- "原密码" below
// is a frontend-only speed bump against fat-fingering this field, not a real credential check.
function ChangePassword({onUpdatePassword, onToast}) {
const [oldPwd, setOldPwd] = React.useState('');
const [newPwd, setNewPwd] = React.useState('');
const [confirmPwd, setConfirmPwd] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = () => {
if (!oldPwd || newPwd.length < 6) { onToast('请输入原密码,新密码至少6位'); return; }
if (newPwd !== confirmPwd) { onToast('两次输入的新密码不一致'); return; }
if (!onUpdatePassword) { onToast('当前环境不支持修改密码'); return; }
onUpdatePassword(newPwd);
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
if (busy) return;
setBusy(true);
// 等真实结果再报成功。原来是发出去就立刻弹"密码已更新"并跳走,服务端拒绝(密码策略不符等)
// 时用户看到的仍然是成功提示,密码其实没变。
Promise.resolve(onUpdatePassword(newPwd)).then(() => {
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
}).catch(err => {
onToast((err && err.message) || '修改失败,请重试');
setBusy(false);
});
};
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成" onAction={submit} /><main>
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成"
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main>
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
<p>Tinode 协议本身不会在服务端校验"原密码",这里的原密码输入是前端自己加的一道二次确认,防止误触。</p>
</main></div>;
}
@@ -415,8 +442,12 @@ function DeleteAccountConfirm({onDeleteAccount, onToast}) {
Promise.resolve(onDeleteAccount()).catch(err => { onToast(err.message || '注销失败'); setBusy(false); });
};
if (step === 1) {
// im_be's replyDelUser -> hub.stopTopicsForUser hard-deletes every group topic this account
// owns (not just leaves them) -- confirmed against hub.go's topic.owner==uid branch. Callers
// in those groups get no advance warning and no forced-ownership-transfer prompt, so this has
// to be disclosed here since it's the only place the user sees before confirming.
return <ConfirmSheet icon="warning" title="注销账号"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。确定要继续吗?"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?"
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
}
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
@@ -437,6 +468,11 @@ function AppVersion({onToast}) {
<section className="personal-menu-section">
<MenuRow title="用户条款" onClick={() => onToast('用户条款')} />
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */}
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} />
</section>
</main>
</div>;
@@ -466,19 +502,23 @@ function PrivacySettings({onToast}) {
if (on.length === 4) return '全部允许';
return on.length ? on.join('、') : '全部关闭';
};
// 后端 userPrivacyPayload 会返回 editable{add_me_methods, profile_visibility, last_seen_visibility,
// read_receipts},用来表达"企业强制策略锁死了这一项"。原来完全没读这个字段,被锁的开关照样能点,
// 点了服务端 400 再回滚,用户只看到开关自己弹回去、不知道为什么。
const locked = key => !!(privacy && privacy.editable && privacy.editable[key] === false);
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list">
<section><MenuRow title="添加我的方式" value={methodSummary()} route="friend-discovery-settings" /></section>
{privacy ? <section>
<div><span><strong>显示消息已读状态</strong><small>关闭后你也无法查看对方的已读状态</small></span>
<Toggle checked={!!privacy.read_receipts} onChange={v => update('read_receipts', v)} /></div>
<div><span><strong>展示最后在线时间</strong><small>仅向联系人显示</small></span>
<div><span><strong>显示消息已读状态</strong><small>{locked('read_receipts') ? '该项由企业统一设置,不可修改' : '关闭后你也无法查看对方的已读状态'}</small></span>
<Toggle checked={!!privacy.read_receipts} disabled={locked('read_receipts')} onChange={v => update('read_receipts', v)} /></div>
<div><span><strong>展示最后在线时间</strong><small>{locked('last_seen_visibility') ? '该项由企业统一设置,不可修改' : '仅向联系人显示'}</small></span>
{/* Backend's validVisibility only accepts everyone/friends/nobody -- 'contacts'/'hidden' were
rejected by the PATCH validator outright (every toggle attempt 400'd and silently reverted),
and even a lenient backend would enforce the opposite of what turning this "on" should do
(imCanViewLastSeen's default case treats any unrecognized value as hidden). */}
<Toggle checked={privacy.last_seen_visibility !== 'nobody'} onChange={v => update('last_seen_visibility', v ? 'friends' : 'nobody')} /></div>
<div><span><strong>允许好友查看我的资料</strong><small>关闭后仅你自己可见</small></span>
<Toggle checked={privacy.profile_visibility !== 'nobody'} onChange={v => update('profile_visibility', v ? 'friends' : 'nobody')} /></div>
<Toggle checked={privacy.last_seen_visibility !== 'nobody'} disabled={locked('last_seen_visibility')} onChange={v => update('last_seen_visibility', v ? 'friends' : 'nobody')} /></div>
<div><span><strong>允许好友查看我的资料</strong><small>{locked('profile_visibility') ? '该项由企业统一设置,不可修改' : '关闭后仅你自己可见'}</small></span>
<Toggle checked={privacy.profile_visibility !== 'nobody'} disabled={locked('profile_visibility')} onChange={v => update('profile_visibility', v ? 'friends' : 'nobody')} /></div>
</section> : null}
</main></div>;
}
@@ -496,11 +536,13 @@ function FriendDiscoverySettings({onToast}) {
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
};
const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}];
// 同 PrivacySettings:企业强制策略锁死时不让点,避免"能点但必然 400 再弹回"。
const methodsLocked = !!(privacy && privacy.editable && privacy.editable.add_me_methods === false);
return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list">
<p className="privacy-note">关闭某种方式后,其他人将无法通过该渠道搜索或添加你。</p>
<p className="privacy-note">{methodsLocked ? '添加方式由企业统一设置,不可修改。' : '关闭某种方式后,其他人将无法通过该渠道搜索或添加你。'}</p>
{privacy ? <section>{rows.map(row => <div key={row.key}>
<span><strong>可通过{row.title}添加我</strong></span>
<Toggle checked={!!(privacy.add_me_methods || {})[row.key]} onChange={v => update(row.key, v)} />
<Toggle checked={!!(privacy.add_me_methods || {})[row.key]} disabled={methodsLocked} onChange={v => update(row.key, v)} />
</div>)}</section> : null}
</main></div>;
}
@@ -561,7 +603,7 @@ function YouthMode({onToast}) {
if (!settings) return;
if (!settings.password_set) {
onToast('请先设置独立密码');
window.setTimeout(() => navigate('identity-check'), 400);
window.setTimeout(() => navigate('identity-check', {ym: 'enable'}), 400);
return;
}
navigate('youth-mode-verify', {ym: 'enable'});
@@ -600,11 +642,22 @@ function YouthModeVerify({hashParams, onToast}) {
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [lockedUntil, setLockedUntil] = React.useState(0);
const [now, setNow] = React.useState(Date.now());
const remainingLockSeconds = lockedUntil ? Math.max(0, Math.ceil((lockedUntil - now) / 1000)) : 0;
// Self-rescheduling tick, not setInterval -- naturally stops once the countdown reaches 0 instead
// of needing separate start/stop bookkeeping.
React.useEffect(() => {
if (remainingLockSeconds <= 0) return;
const id = window.setTimeout(() => setNow(Date.now()), 1000);
return () => window.clearTimeout(id);
}, [remainingLockSeconds]);
React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []);
const submit = () => {
if (password.length !== 4) { setError('请输入4位独立密码'); return; }
if (remainingLockSeconds > 0) { return; }
setBusy(true);
setError('');
// Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode'
@@ -624,7 +677,17 @@ function YouthModeVerify({hashParams, onToast}) {
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => setError(err.message || '验证失败')).finally(() => setBusy(false));
}).catch(err => {
// Server locks out further attempts for retry_after_seconds after too many wrong passwords
// (any request during that window 429s regardless of whether THIS password is correct) --
// without tracking that window here, the button stayed enabled the whole time, letting the
// user fire off more requests that were guaranteed to fail before the lock even lifts.
if (err.reason === 'TOO_MANY_ATTEMPTS' && err.details && err.details.retry_after_seconds) {
setLockedUntil(Date.now() + err.details.retry_after_seconds * 1000);
setNow(Date.now());
}
setError(err.message || '验证失败');
}).finally(() => setBusy(false));
};
return <div className="personal-page youth-mode-page">
@@ -634,7 +697,16 @@ function YouthModeVerify({hashParams, onToast}) {
<input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus
value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" />
{error ? <div className="auth-field-error">{error}</div> : null}
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy} onClick={submit}>{busy ? '验证中…' : '确定'}</button>
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
</button>
{/* 一旦设过密码,这个页面就是唯一入口,之前完全没有出路——真忘了密码只能不断输错直到被
限流,限流也只是让你等,不会给别的办法。identity-check 走的短信验证码本来就支持在
password_set:true 时重新设置密码(实测确认过),只是没有从这里链接过去。
必须带上当前 mode:IndependentPassword 设完新密码后,服务端每次都会无条件带回
enable_action_token(与调用者意图无关),原来因为不知道是从"关闭"这个流程点进来的,
会把这次重置误当成"继续开启",用户想关闭青少年模式却被弹"已开启"、状态也没有真的关掉。 */}
<button type="button" className="auth-text-button" onClick={() => navigate('identity-check', {ym: mode})}>忘记独立密码?</button>
</main>
</div>;
}
+27 -9
View File
@@ -2,7 +2,7 @@ import React from 'react';
import HashNavigation from '../lib/navigation.js';
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { readCachedData, useCachedData } from '../lib/use-cached-data.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
@@ -43,8 +43,13 @@ function Toast({message, onDone}) {
// counterpart: the pre-send URL risk check below (RiskConfirmDialog), which DOES have a real backend
// (POST /security/urls/check + POST /security/risk-challenges/{id}/confirm).
function FraudAwareness({secondary}) {
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span>
// 这里必须带上 risk-dialog:本页的 ul/li/aside/.risk-emphasis/button.primary 全部样式都挂在
// base.css 的 .risk-dialog 下(14 条规则),而 .risk-info-page 在 base.css 里一条规则都没有——
// 只写 risk-info-page 的话整页会渲染成带原生项目符号的裸列表。
// 图标名也修正了:shield_lock 只存在于 Material Symbols,index.html 加载的是经典 Material Icons,
// 那套字体里没有这个字形,会原样显示 "shield_lock" 这串文本。改用仓库已在用的 verified_user。
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page risk-dialog">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'verified_user'}</Icon></span>
{secondary ? <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。</p>
@@ -176,7 +181,13 @@ function UserFeedback({tinode, onToast}) {
// cached object avoids a flash where the category picker renders (gated on `config` alone) with
// nothing selected yet.
const cachedFeedbackConfig = readCachedData('feedback-config');
const [config, setConfig] = React.useState(() => cachedFeedbackConfig || null);
// Was its own useState + one-shot useEffect with no retry -- a failed first fetch with nothing
// cached yet left `config` stuck at null for the rest of the page's life (the category picker
// below never renders since it's gated on `config`, but content/submit stay visible the whole
// time), and the only way out was a full page reload. Routing through useCachedData gets the same
// "one retry after 3s if there's truly nothing cached yet" behavior bootstrap and friends already
// have, for free.
const [config] = useCachedData('feedback-config', getFeedbackConfig, []);
const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
const [content, setContent] = React.useState('');
const [images, setImages] = React.useState([]);
@@ -188,12 +199,14 @@ function UserFeedback({tinode, onToast}) {
const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
const maxLength = (config && config.max_length) || 300;
// Only fills in the default once, the first time a fetch (initial or the useCachedData retry)
// actually resolves without one already picked synchronously above from a warm cache -- doesn't
// clobber a category the user already changed.
React.useEffect(() => {
getFeedbackConfig().then(data => {
writeCachedData('feedback-config', data);
setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');
}).catch(() => {});
}, []);
if (config && config.categories && config.categories.length && !category) {
setCategory(config.categories[0]);
}
}, [config]);
const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};
@@ -209,6 +222,11 @@ function UserFeedback({tinode, onToast}) {
const submit = () => {
if (!content.trim()) { onToast('请填写反馈内容'); return; }
// category is a required backend field (empty string 400s as MALFORMED_REQUEST) but the picker
// that sets it only renders once `config` has loaded -- if getFeedbackConfig() is still in
// flight or failed, `category` can still be '' here while content/submit stay visible the whole
// time, so this needs its own explicit guard rather than relying on the picker having run.
if (!category) { onToast('请选择反馈类型'); return; }
setSubmitting(true);
submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics})
.then(result => setSubmitted(result))
+2
View File
@@ -136,6 +136,7 @@ class SidepanelView extends React.PureComponent {
return <div id="sidepanel" className="auth-onboarding-panel">
<AuthOnboardingView
view={view}
hashParams={this.props.hashParams}
authenticated={!!this.props.myUserId}
login={this.props.login}
loginDisabled={this.props.loginDisabled}
@@ -157,6 +158,7 @@ class SidepanelView extends React.PureComponent {
return <div id="sidepanel" className={'message-center-panel' + (fullPage ? ' full-page' : '')}>
<MessageCenterView
view={view}
key={view}
connected={this.props.connected}
chatList={this.props.chatList}
myUserId={this.props.myUserId}
+270 -66
View File
@@ -29,8 +29,8 @@ import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest } from '../lib/im-api.js';
import { writeCachedData } from '../lib/use-cached-data.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest, getBootstrap } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
@@ -101,6 +101,114 @@ const messages = defineMessages({
}
});
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的
// "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的
// ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的
// 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。
const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','crop','edit','general',
'newtpk','notif','register','reset','security','settings','support','wallpapers',
'tenant','login','enterprise-register','enterprise-feedback','identity-check',
'realname','realname-verified','realname-reminder','independent-password',
'messages-home','messages-offline','messages-menu','message-actions','system-messages',
'chat-inbound-request','chat-inbound-message','chat-outbound-request','chat-status',
'chat-friend','chat-media','chat-blocked','chat-deleted','chat-standard',
'chat-attachments','chat-mixed','chat-contact-card',
'compose-keyboard','mentions-select','mentions-complete','mentions-message',
'emoji-panel','emoji-message','voice-ready','voice-hold','voice-recording',
'media-uploading','media-gallery','media-retry','upload-file','file-message',
'message-multiselect','favorite-success','forward-select','forward-multiple',
'forward-individual','forward-bundle',
'address-book','friend-requests','add-friend','my-friend-qr','friend-qr','friend-search-result',
'friend-verification','friend-profile','group-member-profile','friend-settings',
'friend-settings-blocked','friend-delete-confirm','block-list','block-confirm','clear-chat-confirm',
'group-list','group-empty','group-invite','group-events','group-join-requests',
'group-new-request','group-announcement','group-summary','group-announcement-detail',
'group-settings','group-profile','group-avatar','group-nickname','group-description',
'group-my-nickname','group-members','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading',
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
'call-history','call-incoming','audio-call-ringing','audio-call-active','audio-call-muted',
'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active',
'video-call-peer-camera-off','call-minimized',
'workbench','organization-directory','meeting-organizer','meeting-attendee',
'meeting-create','meeting-detail',
'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail',
'monthly-report-editor','monthly-report-mine','monthly-report-team','monthly-report-detail',
'executive-mailbox','executive-mailbox-notice',
'personal-center','personal-profile','profile-nickname','profile-birth-date',
'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings',
'cache-clear-confirm','clear-history-confirm','change-password','delete-account-confirm',
'app-version','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-on','youth-mode-verify',
'notification-permission','permission-settings-reminder','camera-permission-info',
'microphone-permission-info',
'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons',
'abuse-report-details','user-feedback',
''];
// 登录流程本身的页面。从这些页面登录成功后应该落到工作台;从任何其它页面(刷新触发的自动登录)
// 则应该留在原处。
const AUTH_FLOW_VIEWS = ['tenant', 'login', 'register', 'reset', 'cred',
'enterprise-register', 'enterprise-feedback'];
// Tinode 原生 WS 登录失败走的是 {ctrl} 协议,不是 REST 层——im-api.js 里那张
// REASON_MESSAGES 翻译表只在 REST 请求里生效,这条路径完全绕开它。ctrl.text 是服务端
// datamodel.go 里固定的英文串(authentication failed / unknown authentication scheme 等),
// ctrl.params 里也没有像 REST 层那样稳定的 reason 字段可用(已实测确认)。
// 原来这里直接把 err.message 原样显示,登录密码打错用户看到的是英文 "authentication failed",
// 不是参考图要求的"账号或密码错误"。
//
// 这张表原来按 err.message 精确匹配裸文案——但 tinode-sdk 的 CommError 构造时会把状态码拼进
// message 里("authentication failed (401)"),导致这张表实际上从未真正命中过:'authentication
// failed' 之所以看起来工作正常,纯粹是因为紧接着的 401/403 兜底分支凑巧给出了同一句译文,掩盖
// 了匹配从未成立这件事;一旦命中 'unknown authentication scheme'(401,跟密码错误共用同一个状态
// 码,会被兜底分支误判成"账号或密码错误")或 'policy violation'(422,不在兜底范围内)这两种更
//少见的场景,用户就会看到带着 "(状态码)" 尾巴的原始英文。translateCtrlError 先去掉这段状态码
// 后缀再查表,两个问题一并解决。
//
// 同一张表也被 handleDisconnect 复用,用来翻译 {hi} 阶段(登录请求发出之前)的连接失败——那个
// 场景下 permission denied 最常见的真实原因是租户被停用,不是"账号或密码错误",所以只在这里放
// 一份不带 401/403 兜底的裸翻译(loginErrorMessage 自己的兜底只应用在真正的登录尝试上)。
const CTRL_ERROR_TEXT_CN = {
'authentication failed': '账号或密码错误',
'authentication required': '请先登录',
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
'policy violation': '当前操作不符合安全策略要求',
'permission denied': '没有权限执行此操作,请重新登录'
};
function translateCtrlError(err) {
const raw = err && err.message;
if (!raw) return null;
const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase();
return CTRL_ERROR_TEXT_CN[normalized] || null;
}
function loginErrorMessage(err) {
const known = translateCtrlError(err);
if (known) return known;
// 未知文案时按状态码兜底,401/403 绝大多数场景就是账号或密码错误
if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误';
return (err && err.message) || '登录失败,请重试';
}
// navigateToCredentialsView 拒绝导航时,ctrl.params.reason 用来给用户一句能看懂的话——目前只有
// INVITE_CODE_INVALID/INVITE_CODE_REQUIRED 两个真实用真实请求验证过,REVOKED/EXPIRED 是同一命名
// 规律下合理的兄弟值,未逐一实测,先按同一套文案兜底,不影响已确认的两个分支。
const INVITE_CODE_REASON_TEXT_CN = {
INVITE_CODE_REQUIRED: '请填写邀请码',
INVITE_CODE_INVALID: '邀请码错误,请重新输入',
INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码',
INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码'
};
function invalidCredentialsReasonMessage(params) {
const reason = params && params.reason;
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
}
// See armCallRingTimeout below.
const CALL_RING_TIMEOUT_MS = 60000;
class TinodeWeb extends React.Component {
constructor(props) {
super(props);
@@ -431,7 +539,7 @@ class TinodeWeb extends React.Component {
// Parse the hash navigation params.
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
// Maybe navigate to home screen.
if (!['cred', 'reset', 'register'].includes(parsedNav.path[0])) {
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
// Save possible topic name.
this.setState({requestedTopic: parsedNav.path[1]});
const path = parsedNav.params && parsedNav.params.cred_done ?
@@ -650,49 +758,7 @@ class TinodeWeb extends React.Component {
if (hash.path && hash.path.length > 0) {
// Left-side panel selector.
if (['acc_general','archive','blocked','contacts','cred','crop','edit','general',
'newtpk','notif','register','reset','security','settings','support','wallpapers',
'tenant','login','enterprise-register','enterprise-feedback','identity-check',
'realname','realname-verified','realname-reminder','independent-password',
'messages-home','messages-offline','messages-menu','message-actions','system-messages',
'chat-inbound-request','chat-inbound-message','chat-outbound-request','chat-status',
'chat-friend','chat-media','chat-blocked','chat-deleted','chat-standard',
'chat-attachments','chat-mixed','chat-contact-card',
'compose-keyboard','mentions-select','mentions-complete','mentions-message',
'emoji-panel','emoji-message','voice-ready','voice-hold','voice-recording',
'media-uploading','media-gallery','media-retry','upload-file','file-message',
'message-multiselect','favorite-success','forward-select','forward-multiple',
'forward-individual','forward-bundle',
'address-book','friend-requests','add-friend','my-friend-qr','friend-search-result',
'friend-verification','friend-profile','group-member-profile','friend-settings',
'friend-settings-blocked','friend-delete-confirm','block-list','block-confirm','clear-chat-confirm',
'group-list','group-empty','group-invite','group-events','group-join-requests',
'group-new-request','group-announcement','group-summary','group-announcement-detail',
'group-settings','group-profile','group-avatar','group-nickname','group-description',
'group-my-nickname','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
'favorites-links','favorites-select','favorite-share','file-download','file-downloading',
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
'call-history','call-incoming','audio-call-ringing','audio-call-active','audio-call-muted',
'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active',
'video-call-peer-camera-off','call-minimized',
'workbench','organization-directory','meeting-organizer','meeting-attendee',
'meeting-create','meeting-detail',
'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail',
'monthly-report-editor','monthly-report-list','monthly-report-detail',
'executive-mailbox','executive-mailbox-notice',
'personal-center','personal-profile','profile-nickname','profile-birth-date',
'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings',
'cache-clear-confirm','clear-history-confirm','change-password','delete-account-confirm',
'app-version','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-on','youth-mode-verify',
'notification-permission','permission-settings-reminder','camera-permission-info',
'microphone-permission-info',
'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons',
'abuse-report-details','user-feedback',
''].includes(hash.path[0])) {
if (SIDE_PANEL_VIEWS.includes(hash.path[0])) {
newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else {
@@ -877,13 +943,17 @@ class TinodeWeb extends React.Component {
}, 1000);
}
// Connection lost
// Connection lost. Also covers a {hi} handshake rejected before login ever gets a chance to run
// (e.g. the tenant being deactivated mid-session) -- that path calls onDisconnect, not the {login}
// catch block down in doLogin, so without translateCtrlError here it showed the raw English
// ctrl.text (e.g. "permission denied (403)") instead of any Chinese message at all.
handleDisconnect(err) {
const known = translateCtrlError(err);
this.setState({
connected: false,
ready: false,
topicSelectedOnline: false,
errorText: err && err.message ? err.message : "Disconnected",
errorText: known || (err && err.message ? err.message : "Disconnected"),
errorLevel: err && err.message ? 'err' : 'warn',
loginDisabled: false,
contextMenuVisible: false,
@@ -928,7 +998,9 @@ class TinodeWeb extends React.Component {
if (cred) {
this.handleError(this.props.intl.formatMessage(messages.code_doesnot_match), 'warn');
}
TinodeWeb.navigateToCredentialsView(ctrl.params);
if (!TinodeWeb.navigateToCredentialsView(ctrl.params) && !cred) {
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
}
} else {
this.handleLoginSuccessful();
}
@@ -942,24 +1014,40 @@ class TinodeWeb extends React.Component {
loadSpinnerVisible: false,
autoLogin: autoLogin
});
this.handleError(err.message, 'err');
this.handleError(loginErrorMessage(err), 'err');
console.warn("Login failed", err);
if (!autoLogin) {
if (token) {
// 存的 token 失效(过期/被撤销)——这属于"会话没了",回到登录入口是对的。
this.handleLogout();
HashNavigation.navigateTo('');
}
HashNavigation.navigateTo('');
// 用户手输账号密码失败(401 之类)时**不要**导航。原来这里无条件 navigateTo(''),
// 会把 sidePanelSelected 清成 '',render 里 '' 又落到 'tenant',于是密码打错一次就被
// 弹回企业码页,刚输的账号丢了;而 TenantEntry 不渲染 FormAlert、chapterOne 分支也不渲染
// ErrorPanel,那句错误提示用户根本看不到。留在登录页,errorText 才能显示出来。
}
});
}
// ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created,
// now confirm a real credential (email code etc.)" -- ctrl.params.cred is guaranteed non-empty in
// that case. This backend reuses the exact same code/text pair for its own invite-code errors
// (INVITE_CODE_INVALID/INVITE_CODE_REQUIRED, confirmed live), which carry `reason`/`what` instead
// of `cred` -- calling this without checking left `params.cred[0]` throwing a raw TypeError that
// surfaced to the user as unreadable English. Returns whether it actually navigated, so both call
// sites can show a real error message instead when it didn't.
static navigateToCredentialsView(params) {
if (!Array.isArray(params.cred) || !params.cred.length) {
return false;
}
const parsed = HashNavigation.parseUrlHash(window.location.hash);
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
parsed.params['token'] = params.token;
parsed.params['code'] = params.code;
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
return true;
}
handleLoginSuccessful() {
@@ -1011,7 +1099,15 @@ class TinodeWeb extends React.Component {
// The new bottom nav only has "workbench" and "messages" -- there's no standalone contacts
// entry point anymore, so landing a fresh login there orphaned the user outside the nav they can
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'workbench');
//
// 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件
// 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) ||
!SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ?
HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
}
@@ -1388,7 +1484,9 @@ class TinodeWeb extends React.Component {
{public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments});
}).then(ctrl => {
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
TinodeWeb.navigateToCredentialsView(ctrl.params);
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
}
} else {
this.handleLoginSuccessful(this);
}
@@ -1739,10 +1837,17 @@ class TinodeWeb extends React.Component {
handleUpdatePasswordRequest(password) {
this.handleError();
if (password) {
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password)
.catch(err => this.handleError(err.message, 'err'));
if (!password) {
return Promise.reject(new Error('密码不能为空'));
}
// 必须把 Promise 返回出去:调用方(personal-settings-view 的修改密码页)要靠它判断成败。
// 原来这里只在内部 catch 掉、什么都不返回,调用方拿到 undefined,于是密码策略被服务端拒绝时
// 用户照样看到"密码已更新"并被跳走。跟之前修过的 handleDeleteAccount 是同一类问题。
return this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password)
.catch(err => {
this.handleError(err.message, 'err');
throw err;
});
}
handleChangePermissions(topicName, mode, uid) {
@@ -1944,6 +2049,31 @@ class TinodeWeb extends React.Component {
});
}
// Extracted out of defaultTopicContextMenu so the new 4.9-4.14 attachment-panel call shortcuts
// (send-message.jsx) can gate on exactly the same real-world availability check as the existing
// "..." menu items, instead of re-deriving (and risking drifting from) the same three conditions.
canStartCall(topicName) {
// Never checked whether a call was already in progress -- the "..." menu entry and
// send-message.jsx's attachment-panel shortcut stayed clickable for the whole (very normal,
// product-supported) duration of a minimized call. A second click re-arms
// handleStartVideoCall/handleStartAudioCall's 60s ring timeout and forces callState back to
// CALL_STATE_OUTGOING_INITATED with no visible effect (the already-mounted CallPanel doesn't
// react to that prop), silently hanging up the real, healthy call a full minute later.
if (this.state.callState != CALL_STATE_NONE) {
return false;
}
const topic = this.tinode.getTopic(topicName);
if (!topic || topic._deleted || !Tinode.isP2PTopicName(topicName)) {
return false;
}
const acs = topic.getAccessMode();
const writer = !!(acs && acs.isWriter());
const cachedBootstrap = this.state.myUserId ? 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);
@@ -1955,7 +2085,7 @@ class TinodeWeb extends React.Component {
const me = this.tinode.getMeTopic();
let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false,
archived = false, pinned = false, webrtc = false, writer = false, p2p = false, self = false;
archived = false, pinned = false, webrtc = false, p2p = false, self = false;
if (topic) {
subscribed = topic.isSubscribed();
archived = topic.isArchived();
@@ -1967,11 +2097,16 @@ class TinodeWeb extends React.Component {
blocked = !acs.isJoiner();
self_blocked = !acs.isJoiner('want');
deleter = acs.isDeleter();
writer = acs.isWriter();
}
}
webrtc = writer && !!this.tinode.getServerParam('iceServers');
// 后端今天新增了一个全局(非按租户)的通话总开关:{pub head:webrtc:...} 现在会先过
// im_client_feature 这个开关,关闭时整条消息直接被服务端拒绝。这个开关缺失/查询失败时是
// fail-closed(当成关闭),比 Tinode 协议层的 iceServers 配置更容易出现"某天突然打不通"的
// 情况。canStartCall 统一做这几条判断(也是 4.9-4.14 附件面板通话入口在用的同一份逻辑),
// 按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,这里不做的话用户点了
// 按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。
webrtc = this.canStartCall(topicName);
p2p = Tinode.isP2PTopicName(topicName);
self = Tinode.isSelfTopicName(topicName);
@@ -2257,6 +2392,7 @@ class TinodeWeb extends React.Component {
}
handleStartVideoCall() {
this.armCallRingTimeout();
this.setState({
callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED,
@@ -2265,6 +2401,7 @@ class TinodeWeb extends React.Component {
}
handleStartAudioCall() {
this.armCallRingTimeout();
this.setState({
callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED,
@@ -2272,18 +2409,61 @@ class TinodeWeb extends React.Component {
});
}
// this.callTimeoutTimer was declared (and already read/cleared in handleCallClose) but never
// actually assigned anywhere -- whose 'accept' {note} gets silently dropped, since every
// call-signaling step after the initial invite is a fire-and-forget {note}, not a {pub} with a
// server ack, left the caller's UI stuck on "呼叫中" forever, with manual hang-up the only way out.
// This arms a 60s backstop that reuses the exact same hang-up path as clicking cancel by hand
// (real 'hang-up' note to the peer + the usual top-level state reset), so it doesn't invent any
// new call-outcome semantics.
//
// Note this is a secondary safety net, not the primary defense against a plain unanswered call --
// the backend already has its own call_establishment_timeout (30s, server/main.go in the im_be
// reference repo) that ends a genuinely-unanswered call server-side and notifies both parties via
// {info hang-up}, which handleInfoMessage's 'hang-up' case below already handles independently of
// this timer. For that common case this 60s timer mostly just no-ops (state has already moved to
// CALL_STATE_NONE by the time it fires). What it actually guards against is a narrower case: the
// {info hang-up} notification carrying that server-side timeout is itself lost -- the same class
// of signaling drop this whole mechanism exists for, just at a different stage. Kept comfortably
// above the server's own 30s so it never races or duplicates that primary path. Deliberately scoped
// to just the outgoing-ring wait: adding similar timeouts around mid-call offer/ice-candidate/
// answer signaling would risk false-positive hangups on connections that are just slow, not
// actually dropped.
armCallRingTimeout() {
clearTimeout(this.callTimeoutTimer);
this.callTimeoutTimer = setTimeout(() => {
this.callTimeoutTimer = null;
if (this.state.callState != CALL_STATE_OUTGOING_INITATED) {
return;
}
this.handleCallHangup(this.state.callTopic, this.state.callSeq);
this.handleCallClose();
}, CALL_RING_TIMEOUT_MS);
}
handleCallInvite(callTopic, callSeq, callState, audioOnly) {
switch (callState) {
case CALL_STATE_OUTGOING_INITATED:
const head = { webrtc: CALL_HEAD_STARTED, aonly: !!audioOnly };
// sendMessageToTopic 自己的 .catch 会吞掉发送失败(只标记 pub._failed=true 让消息列表画
// 红色感叹号——这是给聊天气泡设计的反馈方式,通话邀请这条消息不会被用户看到,走这条路径
// 相当于什么反馈都没有),resolve 成 undefined 而不是 reject。原来这里 `ctrl.code` 直接
// 解引用,undefined.code 会抛 TypeError,变成一个没人 catch 的 rejected promise——
// handleCallClose() 根本不会被调用,通话面板会卡在"呼叫中"状态出不来,且用户不知道为什么。
// 今天这条路径已经有真实后果:新的全局通话开关 (im_client_feature) 关闭时服务端会拒绝这条
// {pub},命中的就是这个从未被验证过的失败分支。
return this.handleSendMessage(Drafty.videoCall(audioOnly), undefined, undefined, head)
.then(ctrl => {
if (ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) {
if (!ctrl || ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) {
this.handleError('通话发起失败,请稍后重试', 'err');
this.handleCallClose();
return ctrl;
}
this.setState({callSeq: ctrl.params['seq']});
return ctrl
}).catch(err => {
this.handleError((err && err.message) || '通话发起失败,请稍后重试', 'err');
this.handleCallClose();
});
case CALL_STATE_IN_PROGRESS:
const topic = this.tinode.getTopic(callTopic);
@@ -2347,11 +2527,26 @@ class TinodeWeb extends React.Component {
this.setState({
callTopic: undefined,
callState: CALL_STATE_NONE,
callAudioOnly: undefined
callAudioOnly: undefined,
// callSeq is otherwise only ever set once handleCallInvite's {pub} ack comes back (see its
// .then() below), never cleared -- without resetting it here, a call ended before that ack
// arrives (or armCallRingTimeout's hang-up firing before it) leaves this call's callSeq
// pointing at whatever the *previous* call's seq was, and the next outgoing call could read
// that stale value for the brief window before its own ack lands.
callSeq: undefined,
// handleCallAccept's "topic not subscribed yet" branch (below) sets this and waits for
// handleNewTopicCreated to both flip callState to CALL_STATE_IN_PROGRESS and clear this flag
// once that subscription succeeds -- but a call closed/abandoned (rejected, or the subscribe
// itself failing) before that ever happens left this stuck at true with no owner left to clear
// it. It would then get read by the *next*, entirely unrelated topic subscription that happens
// to succeed, incorrectly forcing callState to CALL_STATE_IN_PROGRESS for a call that was
// already given up on.
callShouldStart: false
});
}
handleCallAccept(topicName, setCallTopic, callSeq) {
clearTimeout(this.callTimeoutTimer);
const topic = this.tinode.getTopic(topicName);
if (!topic) {
return;
@@ -2380,13 +2575,19 @@ class TinodeWeb extends React.Component {
}
switch (info.event) {
case 'accept':
// Either branch below means the outgoing ring wait is over one way or another --
// see armCallRingTimeout.
clearTimeout(this.callTimeoutTimer);
// If another my session has accepted the call.
if (Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) {
this.setState({
callTopic: null,
callState: CALL_STATE_NONE,
callSeq: null,
callAudioOnly: undefined
callAudioOnly: undefined,
// Same stuck-flag risk handleCallClose() guards against -- this call is being given up
// on here just as much as a local close/reject would, so it needs the same cleanup.
callShouldStart: false
});
return;
}
@@ -2451,19 +2652,19 @@ class TinodeWeb extends React.Component {
'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',
'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',
'forward-individual', 'forward-bundle'].includes(activeProductView);
const chapterFiveActive = ['address-book', 'friend-requests', 'add-friend', 'my-friend-qr',
const chapterFiveActive = ['address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr',
'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile',
'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list',
'block-confirm', 'clear-chat-confirm'].includes(activeProductView);
const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events',
'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary',
'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar',
'group-nickname', 'group-description', 'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance',
'group-nickname', 'group-description', 'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance',
'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial',
'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select',
'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView);
const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text',
'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links',
'history-images', 'history-videos'].includes(activeProductView);
const callExperienceActive = ['call-history', 'call-incoming', 'audio-call-ringing',
@@ -2472,7 +2673,7 @@ class TinodeWeb extends React.Component {
'call-minimized'].includes(activeProductView);
const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer',
'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team',
'daily-report-detail', 'monthly-report-editor', 'monthly-report-list',
'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team',
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
@@ -2711,6 +2912,9 @@ class TinodeWeb extends React.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}
+180 -55
View File
@@ -9,7 +9,7 @@ import {
getOrganizationDepartments, getOrganizationDepartmentMembers, getOrganizationMembers, searchOrganization,
getMeetings, createMeeting, getMeeting, cancelMeeting, sendMeetingReminders, respondMeeting,
getDailyReports, createDailyReport, getDailyReport, updateDailyDraft, submitDailyReport, getDailyStatistics, sendDailyReminders,
getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, getMonthlyStatistics,
getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, reopenMonthlyReport, getMonthlyStatistics,
getExecutiveMailboxConfig, submitExecutiveMailboxMessage
} from '../lib/im-api.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
@@ -25,7 +25,7 @@ const navigate = (view, params) => {
export const WORKPLACE_COLLABORATION_VIEWS = [
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail',
'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail',
'monthly-report-editor', 'monthly-report-list', 'monthly-report-detail',
'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail',
'executive-mailbox', 'executive-mailbox-notice'
];
@@ -76,8 +76,8 @@ const nameCache = new Map();
function resolveUserName(id, onDone) {
if (!id) return;
if (nameCache.has(id)) { onDone(nameCache.get(id)); return; }
const withPrefix = id.startsWith('usr') ? id : 'usr' + id;
getUserProfile(id).catch(() => getUserProfile(withPrefix)).then(p => {
const withPrefix = id.startsWith('usr') ? null : 'usr' + id;
getUserProfile(id).catch(err => withPrefix ? getUserProfile(withPrefix) : Promise.reject(err)).then(p => {
const name = (p && (p.display_name || p.nickname || p.public_account)) || id;
nameCache.set(id, name);
onDone(name);
@@ -108,7 +108,7 @@ function useUserNames(ids, myUserId) {
// "not yet available" toast rather than guessing a feature with zero design reference.
const APP_TONES = {organization: 'red', attendance: 'orange', meetings: 'blue', daily_reports: 'orange', monthly_reports: 'violet', executive_mailbox: 'green'};
const APP_ICONS = {organization: 'account_tree', attendance: 'schedule', meetings: 'notifications_active', daily_reports: 'task_alt', monthly_reports: 'event_note', executive_mailbox: 'mail_outline'};
const APP_ROUTES = {organization: 'organization-directory', meetings: 'meeting-attendee', daily_reports: 'daily-report-mine', monthly_reports: 'monthly-report-list', executive_mailbox: 'executive-mailbox'};
const APP_ROUTES = {organization: 'organization-directory', meetings: 'meeting-attendee', daily_reports: 'daily-report-mine', monthly_reports: 'monthly-report-mine', executive_mailbox: 'executive-mailbox'};
function Workbench({onToast}) {
// `me` and `modules` decide whether this screen renders its real content or a blank/loading
@@ -183,12 +183,17 @@ function ImgFromFile({fileId, className}) {
// hollow diamond / (expanded) = filled blue diamond, level 3+ (a person, not a sub-department) =
// hollow circle. The backend never returns a depth field (confirmed against organizationDepartmentPayload),
// so depth is purely how far this node is from the root in the client's own recursion.
// 层级标注统一用中文数字。原来树形分支只手写到"二级"、更深就退化成"3级",
// 而搜索分支又硬传字符串 "三",同一个标注两条路径两种格式。
const LEVEL_NUMERALS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const levelLabel = n => LEVEL_NUMERALS[n - 1] || String(n);
function DeptRow({dept, depth, expanded, onToggle}) {
const icon = depth === 0 ? 'business' : 'diamond';
return <button type="button" className={`organization-department depth-${depth}${expanded ? ' expanded' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={onToggle}>
<Icon>{icon}</Icon>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({depth === 0 ? '一级' : depth === 1 ? '二级' : `${depth + 1}级`})</strong></span>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({levelLabel(depth + 1)}级)</strong></span>
<Icon>{expanded ? 'expand_more' : 'chevron_right'}</Icon>
</button>;
}
@@ -206,7 +211,7 @@ function PersonRow({person, depth, isMe, level}) {
return <button type="button" className={`organization-person${isMe ? ' me' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={() => navigate('group-member-profile', {uid: person.user_id})}>
<Icon>radio_button_unchecked</Icon>
<span><strong>{person.display_name}({level}级)</strong></span>
<span><strong>{person.display_name}({levelLabel(level)}级)</strong></span>
<Icon>chevron_right</Icon>
</button>;
}
@@ -218,14 +223,21 @@ function DeptNode({dept, depth, myUserId}) {
const [children, setChildren] = React.useState(null);
const [members, setMembers] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
const load = () => {
if (children != null) return;
setLoading(true);
setLoadError(false);
Promise.all([
getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []),
dept.child_count ? getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []) : Promise.resolve([]),
dept.member_count ? getOrganizationDepartmentMembers(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])
]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); }).finally(() => setLoading(false));
]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); })
// Without this, a rejected Promise.all left children/members at null forever -- rendered as
// an empty (but not loading) node, indistinguishable from "this department genuinely has no
// sub-departments or members". loadError lets the retry link below tell the two apart.
.catch(() => setLoadError(true))
.finally(() => setLoading(false));
};
const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); };
@@ -234,7 +246,9 @@ function DeptNode({dept, depth, myUserId}) {
return <>
<DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} />
{expanded && loading ? <p className="organization-loading">加载中…</p> : null}
{expanded && !loading ? <>
{expanded && !loading && loadError ?
<p className="organization-loading">加载失败 <a href="#" onClick={e => { e.preventDefault(); load(); }}>重试</a></p> : null}
{expanded && !loading && !loadError ? <>
{(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)}
{(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1}
level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)}
@@ -252,10 +266,10 @@ function durationLabel(startAt, endAt) {
const RESPONSE_LABEL = {accepted: '已接受', declined: '已拒绝', tentative: '待定'};
// Meeting responses already come back with names resolved server-side (organizer.display_name,
// participant.display_name/participant_names[]) -- confirmed against a real created meeting's
// response payload, so no separate getUserProfile lookups are needed here (unlike org-tree/report
// authors, which only ever return bare ids).
// 这条只对 GET /meetings(列表)成立——实测确认只有列表接口会解析真实姓名,MeetingCard 只在
// 列表页使用,不需要额外查询。**创建/详情/更新/取消/我的回应这5个接口不会**(display_name
// 恒等于裸 user_id,第一次记录这条时误以为"响应已带姓名解析"对所有会议接口都成立,被这次复测
// 用真实创建的会议证伪)——MeetingDetail 复用的是这5个接口之一,接了 useUserNames 兜底。
function MeetingCard({meeting, myUserId, onRespond, onToast}) {
const organizerName = (meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name || meeting.organizer_id;
const attendeeNames = (meeting.participants || []).map(p => p.display_name || p.user_id).join('、');
@@ -326,6 +340,17 @@ function MeetingDetail({hashParams, onToast}) {
const myUserId = me && me.user_id;
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
// 详情/创建/更新/取消/我的回应这5个接口的 organizer.display_name / participants[].display_name
// 实测就是裸 user_id 本身(只有列表接口 GET /meetings 真的解析姓名)——上面 MeetingCard 那段
// "响应已带姓名解析、不需要额外查询"的注释只对列表成立,本页面复用同一份 useUserNames 兜底
// (日报/月报详情页已经在用同一套机制)。
const participantIds = (meeting && meeting.participants || []).map(p => p.user_id);
const nameIds = meeting ? [meeting.organizer_id, ...participantIds] : [];
const resolvedNames = useUserNames(nameIds, myUserId);
const nameFor = (id, fallback) => {
if (fallback && fallback !== id) return fallback;
return (id && resolvedNames[id]) || fallback || id;
};
const load = () => meetingId && getMeeting(meetingId).then(m => { writeCachedData('meeting:' + meetingId, m); setMeeting(m); }).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
React.useEffect(() => {
@@ -367,14 +392,14 @@ function MeetingDetail({hashParams, onToast}) {
<header><span><small>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))}</small>
<strong>会议主题:{meeting.title}</strong></span></header>
<dl>
<div><dt>发起人</dt><dd>{(meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name || meeting.organizer_id}</dd></div>
<div><dt>发起人</dt><dd>{nameFor(meeting.organizer_id, (meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name)}</dd></div>
<div><dt>时间</dt><dd>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))} - {formatTime(new Date(meeting.end_at))}</dd></div>
{meeting.location ? <div><dt>地点</dt><dd>{meeting.location}</dd></div> : null}
<div><dt>会议时间</dt><dd>{durationLabel(meeting.start_at, meeting.end_at)}</dd></div>
</dl>
<ul className="meeting-participant-list">
{(meeting.participants || []).map(p => <li key={p.user_id}>
<span>{p.display_name || p.user_id}</span>
<span>{nameFor(p.user_id, p.display_name)}</span>
<em className={p.response}>{RESPONSE_LABEL[p.response] || '待确认'}</em>
</li>)}
</ul>
@@ -390,7 +415,9 @@ function MeetingDetail({hashParams, onToast}) {
<div><span><b>{counts.accepted}</b><small>已接受</small></span><span><b>{counts.pending}</b><small>待响应</small></span><span><b>{counts.declined}</b><small>已拒绝</small></span></div>
<footer>
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
{meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'cancelled' ? <button type="button" className="danger" disabled={busy} onClick={cancel}>取消会议</button> : null}
{/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等,
会议取消之后"取消会议"按钮还挂在那儿 */}
{meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'canceled' ? <button type="button" className="danger" disabled={busy} onClick={cancel}>取消会议</button> : null}
</footer>
</section> : null}
</main>
@@ -431,10 +458,14 @@ function MeetingCreate({myUserId, tinode, onToast}) {
}
const startAt = new Date(`${date}T${startTime}:00`).toISOString();
const endAt = new Date(`${date}T${endTime}:00`).toISOString();
// Backend 400s with {details:{field:"end_at"}} for this (real request confirmed), but nothing
// upstream of onToast reads `details` -- catching it locally avoids a round-trip for the single
// most likely input mistake (single shared date, two independent time inputs).
if (endAt <= startAt) { onToast('结束时间需晚于开始时间'); return; }
setSubmitting(true);
createMeeting({title: title.trim(), start_at: startAt, end_at: endAt, location: location.trim(), attendee_user_ids: selected})
.then(_ => { onToast('会议已发起'); navigate('meeting-attendee'); })
.catch(err => onToast(err.message || '发起会议失败'))
.catch(err => onToast((err.details && err.details.field === 'end_at') ? '结束时间需晚于开始时间' : (err.message || '发起会议失败')))
.finally(() => setSubmitting(false));
};
@@ -466,18 +497,31 @@ function OrganizationDirectory({myUserId}) {
const [query, setQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState(null);
const [roots, setRoots] = React.useState(() => readCachedData('org-roots') || null);
const [rootsError, setRootsError] = React.useState(false);
const [tenantName, setTenantName] = React.useState('');
React.useEffect(() => {
const loadRoots = React.useCallback(() => {
setRootsError(false);
getOrganizationDepartments('root').then(d => {
const list = d.departments || d.items || [];
writeCachedData('org-roots', list);
setRoots(list);
}).catch(() => setRoots(prev => prev || []));
}).catch(() => {
// Was collapsing a genuine load failure into the exact same `roots: []` state as "this
// tenant truly has no departments" -- rootsError lets the render below tell them apart
// instead of showing "暂无组织架构数据" for what might just be a network blip.
setRoots(prev => prev || []);
setRootsError(true);
});
}, []);
React.useEffect(() => {
loadRoots();
try {
const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}');
setTenantName(tenant.name || '');
} catch (_) { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Was calling getOrganizationMembers({query}) -- besides that function's own now-fixed q/query
@@ -503,12 +547,14 @@ function OrganizationDirectory({myUserId}) {
{searchResults.length === 0 ? <p className="organization-loading">未找到匹配的成员</p> :
searchResults.map((item, idx) => item.type === 'department' ?
<SearchDeptRow key={'d' + (item.department && item.department.department_id || idx)} dept={item.department} /> :
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level="三"
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level={3}
isMe={item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)} />)}
</section> :
<section className="organization-tree">
{roots == null ? <p className="organization-loading">加载中…</p> :
roots.length === 0 ? <p className="organization-loading">暂无组织架构数据</p> :
roots.length === 0 ? <p className="organization-loading">
{rootsError ? <>加载失败 <a href="#" onClick={e => { e.preventDefault(); loadRoots(); }}>重试</a></> : '暂无组织架构数据'}
</p> :
roots.map(dept => <DeptNode key={dept.department_id} dept={dept} depth={0} myUserId={myUserId} />)}
</section>}
</main>
@@ -645,7 +691,7 @@ const STATUS_LABEL = {submitted: '已提交', draft: '未提交'};
function DailyReportCard({report, names, team}) {
return <article>
<header><span><strong>{names[report.author_id] || report.author_id}</strong><small className="role-badge">{team ? (report.department_name || '员工') : '员工'}</small></span>
<header><span><strong>{names[report.author_id] || report.author_id}</strong><small className="role-badge">员工</small></span>
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></header>
<p>{report.content_short || '(未填写内容)'}</p>
<footer><span><Icon>schedule</Icon>{report.report_date}</span>
@@ -659,23 +705,33 @@ function DailyReportList({team, myUserId, onToast}) {
// Seed from cache so re-entering "我的日报"/"团队统计" (a real user flow: check the list, go fill
// in today's report, come back) doesn't blank the list out while the identical request re-runs.
const [reports, setReports] = React.useState(() => readCachedData(cacheKey) || null);
const [loadError, setLoadError] = React.useState(false);
const [query, setQuery] = React.useState('');
const [stats, setStats] = React.useState(() => (team && readCachedData(statsCacheKey)) || null);
const ids = (reports || []).map(r => r.author_id);
const names = useUserNames(ids, myUserId);
const load = () => {
setLoadError(false);
// Previously swallowed every failure (network error, 403, ...) into a bare empty/stale list --
// indistinguishable from "genuinely no reports today" (which also renders as an empty list).
// A real failure now surfaces via toast + loadError so the list can say "加载失败" instead of
// silently implying "暂无日报", matching how DailyReportDetail/MonthlyReportList already report
// their own load failures.
getDailyReports({scope: team ? 'managed' : 'mine'}).then(d => {
const list = d.reports || d.items || [];
writeCachedData(cacheKey, list);
setReports(list);
}).catch(() => setReports(prev => prev || []));
}).catch(err => { onToast(err.message || '日报列表加载失败'); setLoadError(true); setReports(prev => prev || []); });
if (team) getDailyStatistics(toDateInputValue(new Date())).then(s => { writeCachedData(statsCacheKey, s); setStats(s); }).catch(() => {});
};
React.useEffect(load, [team]);
const filtered = (reports || []).filter(r => {
const hay = team ? `${names[r.author_id] || ''}${r.department_name || ''}` : r.report_date;
// department_name 后端根本不返回(dailyReportListPayload 的字段只有 report_id/author_id/
// report_date/content_short/status/draft_updated_at/read_state/version),拼进来恒为空串,
// 等于占位符承诺的"部门"那半个搜索永远搜不到任何东西。改为按真实有的字段搜:姓名 + 摘要内容。
const hay = team ? `${names[r.author_id] || ''}${r.content_short || ''}` : r.report_date;
return hay.includes(query);
});
@@ -696,14 +752,14 @@ function DailyReportList({team, myUserId, onToast}) {
<button type="button" onClick={remindMissing}><Icon>notifications_active</Icon>提醒未提交成员</button>
</section> : null}
<label className="workplace-search"><Icon>search</Icon>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名/部门' : '搜索日期'} /></label>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索日期'} /></label>
{!team ? <nav className="report-role-switch">
<button type="button" className="active">我的日报</button>
<button type="button" onClick={() => navigate('daily-report-team')}>团队统计</button>
</nav> : null}
<section className="report-cards">
{reports == null ? <p className="organization-loading">加载中…</p> :
filtered.length === 0 ? <p className="organization-loading">暂无日报</p> :
filtered.length === 0 ? <p className="organization-loading">{loadError ? '日报列表加载失败' : '暂无日报'}</p> :
filtered.map(r => <DailyReportCard key={r.report_id} report={r} names={names} team={team} />)}
</section>
</main>
@@ -761,6 +817,19 @@ function MonthlyReportEditor({onToast}) {
const setVersionBoth = v => { versionRef.current = v; setVersion(v); };
const [summary, setSummary] = React.useState(cached ? cached.summary : '');
const [plan, setPlan] = React.useState(cached ? cached.plan : '');
// monthlyReportDetailPayload's real validation.max_length is 10000, not the 1000 this page used to
// hardcode -- confirmed live (a 2000-char draft save was accepted while this UI would have blocked
// it at 1000). Keep 1000 as the pre-load fallback so the textarea has *some* limit before the first
// response comes back, not because it's the real cap.
const [maxLen, setMaxLen] = React.useState(1000);
// A cached seed can go stale the instant something outside this component's lifetime mutates the
// report's version -- concretely, MonthlyReportDetail's "重新编辑" (reopenMonthlyReport) does that,
// then navigates straight here. Live-reproduced: reopen -> land here with the cache still holding
// the pre-reopen version -> the autosave effect below fired on mount before this component's own
// fresh fetch (below) could correct versionRef, 409'd, and surfaced a scary (and wrong) "请检查
// 网络" toast. Gate the very first autosave on that fresh confirmation whenever there was a cache
// to distrust; skip the wait entirely (unchanged fast path) when there wasn't one.
const [hydrated, setHydrated] = React.useState(!cached);
const [saved, setSaved] = React.useState(false);
const [saveError, setSaveError] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
@@ -789,20 +858,22 @@ function MonthlyReportEditor({onToast}) {
const nextSummary = r.work_summary || ''; const nextPlan = r.next_plan || '';
setReportId(r.report_id); setVersionBoth(r.version);
setSummary(nextSummary); setPlan(nextPlan);
if (r.validation && r.validation.max_length) setMaxLen(r.validation.max_length);
writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, summary: nextSummary, plan: nextPlan});
setHydrated(true);
}).catch(err => onToast(err.message || '创建月报失败'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (!reportId) return undefined;
if (!reportId || !hydrated) return undefined;
setSaved(false);
draftTimerRef.current = window.setTimeout(() => {
draftTimerRef.current = null;
saveDraft(summary, plan).catch(() => setSaveError(true));
}, 600);
return () => window.clearTimeout(draftTimerRef.current);
}, [summary, plan, reportId, saveDraft]);
}, [summary, plan, reportId, hydrated, saveDraft]);
const submit = () => {
if (!summary.trim() || !plan.trim()) { onToast('请完成必填内容'); return; }
@@ -814,50 +885,78 @@ function MonthlyReportEditor({onToast}) {
.finally(() => setSubmitting(false));
};
return <div className="workplace-page report-editor-page"><Header title="月报" back="monthly-report-list" />
return <div className="workplace-page report-editor-page"><Header title="月报" back="monthly-report-mine" />
<main>
<section className="daily-report-block-header"><strong>月报汇总</strong></section>
<label className="report-field"><span><b>*</b>本月工作总结</span>
<textarea value={summary} maxLength="1000" placeholder="请输入" onChange={e => setSummary(e.target.value)} /><small>{summary.length}/1000</small></label>
<textarea value={summary} maxLength={maxLen} placeholder="请输入" onChange={e => setSummary(e.target.value)} /><small>{summary.length}/{maxLen}</small></label>
<label className="report-field"><span><b>*</b>下月工作计划</span>
<textarea value={plan} maxLength="1000" placeholder="请输入" onChange={e => setPlan(e.target.value)} /><small>{plan.length}/1000</small></label>
<textarea value={plan} maxLength={maxLen} placeholder="请输入" onChange={e => setPlan(e.target.value)} /><small>{plan.length}/{maxLen}</small></label>
<p className="report-draft-status">{saveError ? '草稿保存失败,请检查网络后重新编辑' : saved ? '草稿已自动保存' : '正在保存草稿…'}</p>
</main>
<footer className="workplace-submit-bar"><button type="button" disabled={submitting || !reportId} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer>
</div>;
}
function MonthlyReportList({onToast}) {
// Not routed through the shared useCachedData hook (unlike this file's other list/detail pages) --
// that hook's own fetch failure is silently swallowed (see use-cached-data.js's catch) with no way
// for a caller to observe it, only a second `loading` return value nothing here destructures. A
// plain local fetch lets this page track its own error state instead.
const [reports, setReports] = React.useState(() => readCachedData('monthly-reports:mine') || null);
// 团队统计视图镜像 DailyReportList 的同款设计(这个页面原来只有"我的月报"一种,没有任何管理员/
// 团队入口,日报有的东西月报完全没有)。核对 im_be 源码确认后端接口本身是对称的:
// TenantMonthlyReportList 的 scope=managed 分支跟日报那边字面上是同一段
// canUseManagedReports(visibility) 权限校验代码,GET /reports/monthly/statistics 的响应字段
// (expected_count/submitted_count/missing_count)也跟日报统计完全一致——真实请求过一次确认
// 无权限账号会收到 403 PERMISSION_DENIED 而不是被拒之前那种误报全公司数据。
// 唯一确认过不对称的一点:月报没有"提醒未提交成员"这个后端能力(日报有
// POST /reports/daily/reminders,月报连对应路由都不存在),所以团队视图不画这个按钮——
// 画一个点了必炸404的按钮不算"镜像",是给用户挖坑。
function MonthlyReportList({team, myUserId, onToast}) {
const cacheKey = 'monthly-reports:' + (team ? 'managed' : 'mine');
const statsCacheKey = 'monthly-stats:' + toMonthInputValue(new Date());
const [reports, setReports] = React.useState(() => readCachedData(cacheKey) || null);
const [loadError, setLoadError] = React.useState(false);
const [query, setQuery] = React.useState('');
const [stats, setStats] = React.useState(() => (team && readCachedData(statsCacheKey)) || null);
const ids = (reports || []).map(r => r.author_id);
const names = useUserNames(ids, myUserId);
React.useEffect(() => {
getMonthlyReports({scope: 'mine'}).then(d => {
const load = () => {
setLoadError(false);
getMonthlyReports({scope: team ? 'managed' : 'mine'}).then(d => {
const list = d.reports || d.items || [];
writeCachedData('monthly-reports:mine', list);
writeCachedData(cacheKey, list);
setReports(list);
}).catch(err => { onToast(err.message || '月报列表加载失败'); setLoadError(true); });
}, []);
}).catch(err => { onToast(err.message || '月报列表加载失败'); setLoadError(true); setReports(prev => prev || []); });
if (team) getMonthlyStatistics(toMonthInputValue(new Date())).then(s => { writeCachedData(statsCacheKey, s); setStats(s); }).catch(() => {});
};
React.useEffect(load, [team]);
const filtered = (reports || []).filter(r => (r.month || '').includes(query) || (r.work_summary_short || '').includes(query));
const filtered = (reports || []).filter(r => {
const hay = team ? `${names[r.author_id] || ''}${r.work_summary_short || ''}` : r.month;
return (hay || '').includes(query);
});
return <div className="workplace-page report-list-page">
<Header title="月报" action="add" actionLabel="填写月报" onAction={() => navigate('monthly-report-editor')} />
<Header title="月报" action="add" actionLabel="填写月报" onAction={() => navigate('monthly-report-editor')} back={team ? 'monthly-report-mine' : 'workbench'} />
<main>
{team && stats ? <section className="report-statistics">
<header><span><small>{formatMonthLabel(toMonthInputValue(new Date()))}</small><strong>全公司</strong></span>
<button type="button" onClick={() => navigate('monthly-report-mine')}>我的月报</button></header>
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
</section> : null}
<label className="workplace-search"><Icon>search</Icon>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索月份或内容" /></label>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索月份或内容'} /></label>
{!team ? <nav className="report-role-switch">
<button type="button" className="active">我的月报</button>
<button type="button" onClick={() => navigate('monthly-report-team')}>团队统计</button>
</nav> : null}
<section className="report-cards">
{reports == null ? <p className="organization-loading">{loadError ? '月报列表加载失败' : '加载中…'}</p> :
filtered.length === 0 ? <p className="organization-loading">暂无月报</p> :
{reports == null ? <p className="organization-loading">加载中…</p> :
filtered.length === 0 ? <p className="organization-loading">{loadError ? '月报列表加载失败' : '暂无月报'}</p> :
filtered.map(r => <button type="button" key={r.report_id}
onClick={() => navigate((r.action || (r.status === 'submitted' ? 'view' : 'edit')) === 'view' ? 'monthly-report-detail' : 'monthly-report-editor', {report_id: r.report_id})}>
<span className="report-month"><Icon>calendar_month</Icon></span>
<span><strong>{formatMonthLabel(r.month)}</strong><small>工作总结与下月计划</small></span>
<span><strong>{team ? (names[r.author_id] || r.author_id) : formatMonthLabel(r.month)}</strong>
<small>{team ? formatMonthLabel(r.month) : '工作总结与下月计划'}</small></span>
<em className={r.status}>{STATUS_LABEL[r.status] || r.status}</em>
<Icon>chevron_right</Icon>
</button>)}
@@ -870,6 +969,7 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
const reportId = hashParams && hashParams.report_id;
const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null);
const [loadError, setLoadError] = React.useState(false);
const [reopening, setReopening] = React.useState(false);
const names = useUserNames(report ? [report.author_id] : [], myUserId);
React.useEffect(() => {
@@ -877,15 +977,29 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
setReport(readCachedData('monthly-report:' + reportId) || null);
getMonthlyReport(reportId).then(r => { writeCachedData('monthly-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '月报加载失败'); setLoadError(true); });
}, [reportId]);
if (!report) return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
if (!report) return <div className="workplace-page"><Header title="查看详情" back="monthly-report-mine" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" />
// reopenMonthlyReport is self-service only (server keys the UPDATE off the caller's own uid --
// see the comment on the im-api.js export), so this button only ever makes sense for the report's
// own author looking at their own already-submitted report, never a manager viewing someone else's.
const canReopen = report.author_id === myUserId && report.status === 'submitted';
const reopen = () => {
setReopening(true);
reopenMonthlyReport(report.report_id, report.version)
.then(() => { onToast('已重新打开,可继续编辑'); navigate('monthly-report-editor'); })
.catch(err => onToast(err.message || '重新打开失败'))
.finally(() => setReopening(false));
};
return <div className="workplace-page"><Header title="查看详情" back="monthly-report-mine" />
<main className="report-detail">
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
<section><small><Icon>schedule</Icon>{formatMonthLabel(report.month)}</small></section>
<section><h3>本月工作总结</h3><p>{report.work_summary || '未填写'}</p></section>
<section><h3>下月工作计划</h3><p>{report.next_plan || '未填写'}</p></section>
{canReopen ? <section><button type="button" className="workplace-primary" disabled={reopening} onClick={reopen}>
{reopening ? '处理中…' : '重新编辑'}</button></section> : null}
</main>
</div>;
}
@@ -915,11 +1029,22 @@ function ExecutiveMailbox({tinode, noticeOnly, onToast}) {
.catch(() => setConfig(prev => prev || {enabled: false}));
}, []);
const pickFile = () => fileInputRef.current && fileInputRef.current.click();
// Real tenant config uses attachment_policy.max_files (confirmed live -- matches the key the
// 意见反馈 config uses); the Go-side struct literal that only ever backs a tenant with no config
// row at all names it max_count instead, so check both rather than assuming a single unlimited
// upload button is fine. Previously nothing here capped the count at all.
const attachmentPolicy = (config && config.attachment_policy) || {};
const maxFiles = attachmentPolicy.max_files || attachmentPolicy.max_count || 5;
const pickFile = () => {
if (attachments.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 个附件`); return; }
fileInputRef.current && fileInputRef.current.click();
};
const handleFile = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (attachments.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 个附件`); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => {
setAttachments(v => v.concat({fileId, name: file.name}));
@@ -952,8 +1077,8 @@ function ExecutiveMailbox({tinode, noticeOnly, onToast}) {
<label className="mailbox-content"><span>反馈内容</span>
<textarea value={content} maxLength={(config && config.max_length) || 1000} placeholder="请输入" onChange={e => setContent(e.target.value)} />
<small>{content.length}/{(config && config.max_length) || 1000}</small></label>
<span>附件</span>
<button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading}>
<span>附件({attachments.length}/{maxFiles})</span>
<button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading || attachments.length >= maxFiles}>
<Icon>{uploading ? 'hourglass_top' : 'upload_file'}</Icon>
<span>{uploading ? '上传中…' : '上传文件'}</span>
</button>
@@ -990,7 +1115,7 @@ export default function WorkplaceCollaborationView({view, hashParams, myUserId,
else if (view === 'daily-report-editor') content = <DailyReportEditor onToast={setToast} />;
else if (view === 'monthly-report-editor') content = <MonthlyReportEditor onToast={setToast} />;
else if (view === 'daily-report-mine' || view === 'daily-report-team') content = <DailyReportList team={view === 'daily-report-team'} myUserId={myUserId} onToast={setToast} />;
else if (view === 'monthly-report-list') content = <MonthlyReportList onToast={setToast} />;
else if (view === 'monthly-report-mine' || view === 'monthly-report-team') content = <MonthlyReportList team={view === 'monthly-report-team'} myUserId={myUserId} onToast={setToast} />;
else if (view === 'daily-report-detail') content = <DailyReportDetail hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
else if (view === 'monthly-report-detail') content = <MonthlyReportDetail hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
else content = <ExecutiveMailbox tinode={tinode} noticeOnly={view === 'executive-mailbox-notice'} onToast={setToast} />;
+20 -1
View File
@@ -6,6 +6,24 @@ import { createFileDownloader } from '../lib/im-api.js';
import { bytesToHumanSize } from '../lib/strformat.js';
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
// Same broad-category icon+color convention already established for file cards elsewhere
// (content-library-view.jsx / group-management-view.jsx's FILE_TYPE_META) -- this was the one place
// that never adopted it and instead used the same generic gray icon for every attachment regardless
// of type. This client-side mime sniff exists only because chat attachments (unlike a group file
// or a favorite) don't carry a server-computed file_type field to look up directly.
const ATTACHMENT_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
function attachmentTypeFromMime(mime) {
const m = (mime || '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
if (m === 'application/pdf' || m === 'text/plain' || m.includes('msword') || m.includes('wordprocessingml') ||
m.includes('spreadsheetml') || m.includes('presentationml') || m === 'application/vnd.ms-excel' || m === 'application/vnd.ms-powerpoint') {
return 'document';
}
return 'file';
}
export default class Attachment extends React.Component {
constructor(props) {
super(props);
@@ -78,9 +96,10 @@ export default class Attachment extends React.Component {
}
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" /></>;
const [icon, color] = ATTACHMENT_TYPE_META[attachmentTypeFromMime(this.props.mimetype)];
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div><i className="material-icons big" style={{color}}>{icon}</i></div>
<div className="flex-column">
<div>{filename} {size}</div>
{this.props.uploading || this.state.downloader ?
+26 -1
View File
@@ -19,6 +19,11 @@ const THUMB_COLOR = '#1781d7'; // average between dark and light accent colors.
// Minimum number of amplitude bars to draw.
const MIN_PREVIEW_LENGTH = 16;
// 7.5 "列表中同时只能有一条语音播放":favorites/history 列表和聊天气泡里的每条语音各自是一个独立
// AudioPlayer 实例,彼此不知道对方的存在。Module-scoped(不是某个列表父组件的 state)所以跨 tab、
// 跨收藏/聊天记录也生效,不只是同一个列表内部。
let activeAudioPlayer = null;
const messages = defineMessages({
icon_title_play: {
id: 'icon_title_play',
@@ -69,6 +74,9 @@ class AudioPlayer extends React.PureComponent {
}
componentWillUnmount() {
if (activeAudioPlayer === this) {
activeAudioPlayer = null;
}
if (this.audioPlayer) {
this.audioPlayer.onloadedmetadata = null;
this.audioPlayer.ontimeupdate = null;
@@ -78,6 +86,14 @@ class AudioPlayer extends React.PureComponent {
}
}
// Called on the previously-playing instance by whichever other instance is about to start.
pauseForOtherPlayer() {
if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.setState({playing: false});
}
componentDidUpdate(prevProps) {
if (this.props.src != prevProps.src) {
this.initAudio();
@@ -100,7 +116,10 @@ class AudioPlayer extends React.PureComponent {
});
this.audioPlayer.onended = _ => {
this.audioPlayer.currentTime = 0;
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)})
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)});
if (activeAudioPlayer === this) {
activeAudioPlayer = null;
}
}
}
@@ -223,6 +242,12 @@ class AudioPlayer extends React.PureComponent {
} else if (this.audioPlayer.readyState >= 2) {
this.audioPlayer.play();
this.setState({playing: true}, this.visualize);
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
// 有没有被标记已读(原生 Tinode recv/read),跟"这条语音有没有被真的点开听过"是两件事。
// 可选 prop,不传不影响任何现有调用点(收藏页/聊天记录检索也在用这个组件,不需要这个信号)。
if (this.props.onPlay) {
this.props.onPlay();
}
}
}
+56 -4
View File
@@ -117,6 +117,7 @@ class AudioRecorder extends React.PureComponent {
}
componentWillUnmount() {
this._unmounted = true;
this.startedOn = null;
if (this.stream) {
this.cleanUp();
@@ -155,7 +156,7 @@ class AudioRecorder extends React.PureComponent {
if (duration > MAX_DURATION) {
this.startedOn = null;
this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn;
this.durationMillis = duration;
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)});
}
@@ -237,9 +238,24 @@ class AudioRecorder extends React.PureComponent {
e.preventDefault();
this.durationMillis = 0;
this.startedOn = null;
this.mediaRecorder.stop();
// getUserMedia hasn't necessarily resolved yet -- a real, reachable race even outside the new
// 4.7-4.9 press-and-hold gesture (send-message.jsx can now call this within moments of mount):
// tap record then immediately tap delete, before the permission prompt (or this promise) settles,
// and mediaRecorder/stream are still null. handleDone already guarded its own `.stop()` call for
// exactly this reason; this one never did, so it 500'd with "Cannot read properties of null
// (reading 'stop')" instead of just cancelling a recording that never really started.
if (this.mediaRecorder) {
this.mediaRecorder.stop();
}
this.cleanUp();
this.setState({recording: false});
// onDeleted/onFinished normally only fire from mediaRecorder.onstop (below) -- if there's no
// mediaRecorder, that event can never happen, so the parent (send-message.jsx's audioRec state,
// driving whether this component is even mounted) would never hear back and stay stuck showing
// the recorder UI forever. Nothing was actually captured in this case, so call it directly.
if (!this.mediaRecorder && this.props.onDeleted) {
this.props.onDeleted();
}
}
handleDone(e) {
@@ -252,6 +268,11 @@ class AudioRecorder extends React.PureComponent {
// Stop recording and return data.
if (this.mediaRecorder) {
this.mediaRecorder.stop();
} else if (this.props.onDeleted) {
// Same never-started race as handleDelete -- nothing was recorded, so there's nothing to send.
// Treat it as a cancel (onDeleted) rather than silently leaving the parent's audioRec state
// (and this component) stuck open with no way forward.
this.props.onDeleted();
}
}
@@ -268,6 +289,15 @@ class AudioRecorder extends React.PureComponent {
}
initMediaRecording(stream) {
if (this._unmounted) {
// getUserMedia()'s permission prompt can still be pending when this component gets torn
// down (e.g. the composer became disabled mid-request). Without this guard, the promise
// resolving afterwards would go on to call mediaRecorder.start()/visualize() (the latter
// reads this.canvasRef, already null post-unmount) against a component nothing can stop
// anymore -- the mic would keep running with no UI able to end it.
stream.getTracks().forEach(t => t.stop());
return;
}
this.stream = stream;
AUDIO_MIME_TYPES.some(mimeType => {
if (MediaRecorder.isTypeSupported(mimeType)) {
@@ -379,12 +409,29 @@ class AudioRecorder extends React.PureComponent {
if (this.audioInput) {
this.audioInput.disconnect();
}
this.stream.getTracks().forEach(track => track.stop());
// Same never-actually-started race as handleDelete's mediaRecorder guard above -- this.stream is
// still null until getUserMedia resolves, and componentWillUnmount already knew to check before
// calling this; this call site (handleDelete) didn't.
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');
// 4.7-4.9 按住说话:send-message.jsx 现在用一个 ref 直接调这个组件的 handleDone/handleDelete
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
if (this.props.holdMode) {
return (
<div className={'audio audio-hold' + (this.props.cancelling ? ' cancelling' : '')}>
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
<div className="duration">{this.state.duration}</div>
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div>
</div>
);
}
return (
<div className="audio">
<a href="#" onClick={this.handleDelete} title={formatMessage(messages.icon_title_delete)}>
@@ -416,4 +463,9 @@ class AudioRecorder extends React.PureComponent {
}
}
export default injectIntl(AudioRecorder);
// forwardRef:true so send-message.jsx's ref (needed to drive handleDone/handleDelete externally
// for the 4.7-4.9 press-and-hold gesture) actually reaches the class instance instead of the
// injectIntl wrapper -- without this option injectIntl's wrapper is a function component and a
// plain ref on it silently resolves to null (React warns "Function components cannot be given
// refs"), which was live-caught leaving the recorder UI stuck open with no way to finish the hold.
export default injectIntl(AudioRecorder, {forwardRef: true});
+51 -6
View File
@@ -12,6 +12,11 @@ import SearchContacts from './search-contacts.jsx';
import { forwardMessages } from '../lib/im-api.js';
import { topicDisplayName } from '../lib/utils.js';
import { readCachedData } from '../lib/use-cached-data.js';
// 与服务端 im_http.go:3560 的硬限一致。目标数另有一层更严的产品限制,见 maxTargets()。
const MAX_FORWARD_SOURCES = 100;
const MAX_FORWARD_TARGETS = 20;
export default class BatchForwardDialog extends React.Component {
constructor(props) {
@@ -22,6 +27,7 @@ export default class BatchForwardDialog extends React.Component {
multiSelect: false,
selectedTargets: [],
confirmTargets: null,
comment: '',
sending: false,
error: null
};
@@ -34,6 +40,7 @@ export default class BatchForwardDialog extends React.Component {
this.handleConfirmTargets = this.handleConfirmTargets.bind(this);
this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
this.handleSend = this.handleSend.bind(this);
this.handleCommentChange = this.handleCommentChange.bind(this);
}
componentDidMount() {
@@ -73,10 +80,25 @@ export default class BatchForwardDialog extends React.Component {
this.setState({confirmTargets: [uid]});
return;
}
this.setState(s => ({
selectedTargets: s.selectedTargets.includes(uid) ?
s.selectedTargets.filter(t => t != uid) : s.selectedTargets.concat(uid)
}));
this.setState(s => {
if (s.selectedTargets.includes(uid)) {
return {selectedTargets: s.selectedTargets.filter(t => t != uid), error: null};
}
// 目标数上限:优先用 bootstrap 下发的 limits.max_forward_targets(服务端当前给 9),
// 拿不到就退到服务端硬限 20(im_http.go:3560)。原来两个都不读,选到 21 个才会撞 400。
const max = this.forwardTargetLimit();
if (s.selectedTargets.length >= max) {
return {error: `最多同时转发给 ${max} 个会话`};
}
return {selectedTargets: s.selectedTargets.concat(uid), error: null};
});
}
forwardTargetLimit() {
// 读各视图已经在写的同一份 bootstrap 缓存('bootstrap:'+myUserId),不额外发请求。
const boot = this.props.myUserId ? readCachedData('bootstrap:' + this.props.myUserId) : null;
const configured = boot && boot.limits && Number(boot.limits.max_forward_targets);
return configured > 0 ? Math.min(configured, MAX_FORWARD_TARGETS) : MAX_FORWARD_TARGETS;
}
handleConfirmTargets(e) {
@@ -86,15 +108,33 @@ export default class BatchForwardDialog extends React.Component {
}
}
// 转发模式由"用哪种方式选的目标"决定,这是参考图给的规则:
// 转发—逐条转发.png 右上"多选"未点,单选一个目标,占位写 [逐条转发]共2条消息
// 转发—合并转发—多选状态.png 右上已切"确定",勾选多个目标,占位写 [合并转发]共16条消息
// 服务端两种 mode 现在都支持了(bundle 此前返回 501,已实现)。
forwardMode() {
return this.state.multiSelect ? 'bundle' : 'separate';
}
handleCancelConfirm() {
this.setState({confirmTargets: null, error: null});
}
handleCommentChange(e) {
this.setState({comment: e.target.value});
}
handleSend() {
// seq must be a JSON string (server struct tag is `json:"seq,string"`), same as favoriteMessages.
const sources = this.props.seqs.map(seq => ({topic: this.props.topicName, seq: String(seq)}));
// 服务端硬卡 sources ≤ 100、target_topics ≤ 20(im_http.go:3560),超了直接 400 MALFORMED_REQUEST,
// 用户只会看到一句无从下手的"转发失败"。在本地先拦住并说清楚超了多少。
if (sources.length > MAX_FORWARD_SOURCES) {
this.setState({error: `一次最多转发 ${MAX_FORWARD_SOURCES} 条消息,当前已选 ${sources.length} 条`});
return;
}
this.setState({sending: true, error: null});
forwardMessages('separate', sources, this.state.confirmTargets).then(result => {
forwardMessages(this.forwardMode(), sources, this.state.confirmTargets, this.state.comment.trim()).then(result => {
const failed = (result && result.failed) || [];
if (failed.length) {
this.setState({sending: false, error: `${failed.length} 条转发失败,请重试`});
@@ -119,7 +159,11 @@ export default class BatchForwardDialog extends React.Component {
<i className="material-icons">chevron_right</i>
</li>)}
</ul>
<div className="batch-forward-comment">{'[逐条转发]共' + this.props.seqs.length + '条消息'}</div>
{/* 参考图这一栏是可输入的留言框,原来是个只读 <div>,需求 4.19 与对接文档要求的
"附加留言"根本没法填,comment 永远是空串。方括号那段是占位文案,不是内容。 */}
<input className="batch-forward-comment" type="text" value={this.state.comment}
onChange={this.handleCommentChange} disabled={this.state.sending}
placeholder={(this.forwardMode() === 'bundle' ? '[合并转发]共' : '[逐条转发]共') + this.props.seqs.length + '条消息'} />
{this.state.error ? <div className="auth-field-error">{this.state.error}</div> : null}
<div className="batch-forward-actions">
<button type="button" onClick={this.handleCancelConfirm} disabled={this.state.sending}>取消</button>
@@ -149,6 +193,7 @@ export default class BatchForwardDialog extends React.Component {
</div>
</div>
<SearchContacts placeholder="搜索联系人" onSearchContacts={this.handleSearchContacts} />
{this.state.error ? <div className="auth-field-error">{this.state.error}</div> : null}
<ContactList
tinode={this.props.tinode}
contacts={contacts}
+6
View File
@@ -33,6 +33,12 @@ export default class CallMessage extends React.PureComponent {
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
break;
}
} else if (['accepted', 'started'].includes(this.props.callState) && !this.props.duration) {
// Mirrors call-status.jsx's identical branch (the conversation-list preview for the same
// message) -- this chat-bubble sibling was missing it, so a call left running (the product
// supports messaging while a call is minimized/in progress) showed "0:00" here instead of
// "in progress" until the call actually ended and the message got its final duration.
duration = <FormattedMessage id="call_in_progress" defaultMessage="in progress" description="Label for call in progress" />;
} else {
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
}
+75 -7
View File
@@ -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);
@@ -192,6 +194,7 @@ class CallPanel extends React.PureComponent {
}
componentWillUnmount() {
this._unmounted = true;
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onInfo = this.previousOnInfo;
this.stop();
@@ -223,6 +226,16 @@ class CallPanel extends React.PureComponent {
onInfo(info) {
if (info.what != 'call') {
// This call panel takes over topic.onInfo wholesale for the lifetime of the call (including
// while minimized -- the product lets messaging continue during a call), but was only ever
// handling 'call' events itself and dropping everything else on the floor instead of forwarding
// to the handler it displaced (messages-view.jsx's handleInfoReceipt, which drives the "typing"
// indicator and read/received checkmarks). Real-time {info} isn't replayed later, so every
// typing/read/recv update for this topic was silently lost for as long as the panel stayed
// mounted.
if (this.previousOnInfo) {
this.previousOnInfo(info);
}
return;
}
switch (info.event) {
@@ -277,6 +290,16 @@ class CallPanel extends React.PureComponent {
// Start local video.
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
.then(stream => {
// The permission prompt this getUserMedia call triggers can still be pending when the call
// is torn down from elsewhere (the ring-timeout hangup, a hangup note from the peer, or the
// messages-view.jsx componentWillUnmount hangup added this round) -- resolving after that
// touches this.localRef.current (already null post-unmount, so the srcObject assignment
// below would throw) and would otherwise go on to announce an invite for a call nothing is
// showing anymore.
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return;
}
if (!this.localStreamConstraints.video) {
// Starting an audio-only call. Create a dummy video track
// (so video can be enabled during the call if the user desires).
@@ -560,6 +583,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
@@ -591,7 +627,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.
@@ -670,11 +706,18 @@ class CallPanel extends React.PureComponent {
.then(stream => {
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
// Same permission-prompt-outlives-the-call race as start() above -- replaceLocalVideoTrack
// touches this.localRef.current/this.state.localStream, both invalid once unmounted.
if (this._unmounted) {
track.stop();
return;
}
return this.replaceLocalVideoTrack(track);
})
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
.catch(err => this.handleGetUserMediaError(err))
.finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
// Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
.finally(_ => { if (!this._unmounted) { this.setState({videoToggleInProgress: false}); } });
}
// Data channel exists as soon as the peer connection is created, but isn't actually usable
@@ -710,10 +753,15 @@ class CallPanel extends React.PureComponent {
}
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
// Same permission/device-enumeration-outlives-the-call race as start()/unmuteVideo().
if (this._unmounted) {
track.stop();
return null;
}
return this.replaceLocalVideoTrack(track);
})
.catch(err => this.handleGetUserMediaError(err))
.finally(_ => { this.setState({videoToggleInProgress: false}); });
.finally(_ => { if (!this._unmounted) { this.setState({videoToggleInProgress: false}); } });
});
}
@@ -758,9 +806,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));
}
@@ -795,10 +849,18 @@ class CallPanel extends React.PureComponent {
if (this.props.minimized && this.state.audioOnly) {
// Voice calls minimize to a small pill (icon + timer), not the video-shaped box.
return (
// The restore button used to live inside .call-pill.draggable -- dragStart only checks
// event.target.closest('.draggable') before calling setPointerCapture() on the whole
// container, and once a pointer is captured, Pointer Events retargets subsequent events
// (including the click) to the capturing element instead of the button underneath it. The
// video-minimized layout below already keeps its own restore button as a sibling of the
// draggable region for exactly this reason; this pill just hadn't followed the same rule.
<div id="video-container" className="minimized audio-minimized" ref={this.containerRef}>
<div className="call-pill draggable">
<i className="material-icons">call</i>
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
<div className="call-pill-row">
<div className="call-pill draggable">
<i className="material-icons">call</i>
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
</div>
<button className="full-screen" onClick={this.props.onToggleMinimize}>
<i className="material-icons">{fullScreen}</i>
</button>
@@ -836,6 +898,12 @@ class CallPanel extends React.PureComponent {
title={this.props.title} />
</div>
<div className="caller-name">{peerTitle}</div>
{/* This same !remoteActive branch also renders once the call is live but the peer's
own camera happens to be off -- only the pre-connect phase should claim we're
still waiting on them to answer. Reference screenshots (视频通话—等待对方接受
邀请中.png etc.) and this project's own call-experience-view.jsx demo both show
this line; only the real call-panel.jsx was missing it. */}
{!connected ? <p className="caller-status">正在等待对方接受邀请…</p> : null}
</div>
</>
}
+75 -10
View File
@@ -5,6 +5,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
import Attachment from './attachment.jsx';
import ForwardBundle from './forward-bundle.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
@@ -12,12 +13,41 @@ import { fullFormatter } from '../lib/formatters.js';
import { sanitizeUrl } from '../lib/utils.js';
import HashNavigation from '../lib/navigation.js';
// 4.9 语音未读红点:纯客户端概念,没有对应的后端字段——原生 Tinode 的 recv/read 只标记到
// "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算
// 读过,语音消息可能只是划过去、根本没点开播放)。按话题分别记一份"已播放过的 seq 集合"在
// localStorage,跟"最近使用表情"是同一类不需要后端参与的本地小状态。
// Key还要按登录账号分——P2P话题名本身就是对方user_id,跟"我是谁"无关;这个应用已知同一浏览器
// origin下多个账号共享localStorage(多标签页各自登录不同账号),不带uid会导致账号A点开播放过
// 的语音,在账号B自己与同一个人/同一个群的独立会话里也显示成"已读"。
const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:';
function isVoicePlayed(myUid, topic, seq) {
try {
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic) || '[]');
return Array.isArray(list) && list.includes(seq);
} catch (_) {
return false;
}
}
function markVoicePlayed(myUid, topic, seq) {
try {
const key = VOICE_PLAYED_KEY_PREFIX + myUid + ':' + topic;
const list = JSON.parse(localStorage.getItem(key) || '[]');
const set = Array.isArray(list) ? list : [];
if (!set.includes(seq)) {
// Cap so a very long-lived topic doesn't grow this without bound.
localStorage.setItem(key, JSON.stringify(set.concat(seq).slice(-500)));
}
} catch (_) { /* ignore */ }
}
class BaseChatMessage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
progress: 0
progress: 0,
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
};
if (props.uploader) {
@@ -31,13 +61,18 @@ class BaseChatMessage extends React.PureComponent {
this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDraftyClick = this.handleDraftyClick.bind(this);
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
this.formatterContext = {
formatMessage: props.intl.formatMessage.bind(props.intl),
viewportWidth: props.viewportWidth,
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
onHandleClick: this.handleDraftyClick
onHandleClick: this.handleDraftyClick,
// formatters.js's 'AU' case wires this to AudioPlayer's onPlay -- see handleVoicePlayed below
// for why this needs to live on the instance (closes over this specific message's seq/topic)
// rather than being a static, prop-derived value like the other formatterContext entries.
onVoicePlayed: this.handleVoicePlayed
};
}
@@ -161,6 +196,7 @@ class BaseChatMessage extends React.PureComponent {
seq: this.props.seq,
replace: this.props.edited ? parseInt(this.props.edited.split(':')[1]) : 0,
content: this.props.content,
head: this.props.head,
userFrom: this.props.userFrom,
userName: this.props.userName,
y: e.pageY,
@@ -179,6 +215,14 @@ class BaseChatMessage extends React.PureComponent {
this.props.onCancelUpload(this.props.seq, this.props.uploader);
}
handleVoicePlayed() {
if (this.state.voicePlayed) {
return;
}
markVoicePlayed(this.props.tinode.getCurrentUserID(), this.props.topic, this.props.seq);
this.setState({voicePlayed: true});
}
handleQuoteClick(e) {
e.preventDefault();
e.stopPropagation();
@@ -214,21 +258,41 @@ class BaseChatMessage extends React.PureComponent {
const isFailed = !this.props.response &&
(this.props.received == Tinode.MESSAGE_STATUS_FAILED || this.props.received == Tinode.MESSAGE_STATUS_FATAL);
const failReason = this.renderFailReason();
// 4.9 语音未读红点:只对收到的(response=true)语音消息生效——自己发的语音不需要提醒自己听。
let isVoiceMessage = false;
if (this.props.response && this.props.mimeType == Drafty.getContentType() && Drafty.isValid(this.props.content)) {
Drafty.entities(this.props.content, (_0, _1, tp) => {
if (tp === 'AU') { isVoiceMessage = true; }
return isVoiceMessage;
});
}
const showVoiceUnreadDot = isVoiceMessage && !this.state.voicePlayed;
let textSizeClass = 'message-content';
const fullDisplay = (this.props.isGroup && this.props.response &&
(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;
}
// 合并转发(服务端 mode="bundle" 生成的消息)同样不是 Drafty,content 是
// {bundle_id, title, item_count, preview, items, source_refs, comment}。不认这个类型的话
// 一样会落到下面的 "invalid content" 兜底。渲染成一张可展开的"聊天记录"卡片。
let bundleCard = null;
if (this.props.head && this.props.head['x-im-type'] === 'forward_bundle' &&
content && typeof content == 'object' && (content.items || content.preview)) {
bundleCard = <ForwardBundle content={content} tinode={this.props.tinode} />;
}
const attachments = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, (att, i) => {
@@ -286,9 +350,10 @@ class BaseChatMessage extends React.PureComponent {
<i className="material-icons red message-fail-icon">error</i> : null}
<div>
<div className={bubbleClass}>
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
<div className="content-meta">
<div className={textSizeClass}>
{content}
{bundleCard || content}
{attachments}
</div>
{this.props.timestamp ?
+9
View File
@@ -608,6 +608,15 @@ class ContextMenu extends React.Component {
return;
}
const msg = topic.createMessage(params.content, false);
// createMessage only ever fills in head.mime for non-plain-text content -- the original
// message's x-im-type/reply/mentions/mention_all live in params.head (set by
// chat-message.jsx's handleContextClick from the props it was rendered with) and would
// otherwise be silently dropped, unlike the auto-retry path (messages-view.jsx's retrySend)
// which already forwards the original head. Same merge tinode-web.jsx's
// sendMessageToTopic does for a fresh send.
if (params.head) {
msg.head = Object.assign(msg.head || {}, params.head);
}
return topic.publishDraft(msg).catch(err => {
if (errorHandler) {
errorHandler(err.message, 'err');
+69
View File
@@ -0,0 +1,69 @@
// 合并转发卡片(第4章 4.20)。
//
// 服务端在 `POST /v1/im/messages/forward` 的 mode="bundle" 下会生成一条消息,
// head 带 `x-im-type: "forward_bundle"`,content 形如:
// {bundle_id, title, item_count, preview:[{from,type,summary,ts}], items:[...], source_refs, comment}
// 这个结构不是 Drafty,所以不加这个渲染的话会落到 chat-message 的 "invalid content" 兜底。
//
// items[] 里已经带了每条被转发消息的完整快照,所以"查看完整记录"是就地展开,不需要额外请求。
import React from 'react';
// 快照里的 from 是不带 usr 前缀的裸 uid(与收藏快照一致)。先补前缀再查本地会话缓存拿显示名,
// 查不到就不显示发送者——宁可少一行,也不要把一串 uid 摆在摘要前面。
function senderName(tinode, from) {
const raw = (from || '').trim();
if (!raw) return '';
const uid = raw.startsWith('usr') ? raw : 'usr' + raw;
const topic = tinode && tinode.getTopic ? tinode.getTopic(uid) : null;
const pub = topic && topic.public;
return (pub && (pub.fn || pub.name)) || '';
}
const TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
function lineText(entry) {
if (!entry) return '';
const summary = (entry.summary || entry.text || '').trim();
if (summary) return summary;
return TYPE_LABEL[entry.type] || '';
}
export default class ForwardBundle extends React.PureComponent {
constructor(props) {
super(props);
this.state = {expanded: false};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle(e) {
e.preventDefault();
e.stopPropagation();
this.setState(s => ({expanded: !s.expanded}));
}
render() {
const c = this.props.content || {};
const count = Number(c.item_count) || (Array.isArray(c.items) ? c.items.length : 0);
// 收起时用 preview(服务端已截到最多 3 条);展开时用 items(全量)。
const rows = this.state.expanded ? (c.items || []) : (c.preview || []);
const comment = (c.comment || '').trim();
return <div className="forward-bundle">
<div className="forward-bundle-title">{c.title || '聊天记录'}</div>
<ul className="forward-bundle-lines">
{rows.map((entry, i) => {
const who = senderName(this.props.tinode, entry.from);
return <li key={(entry.seq != null ? entry.seq : i) + ':' + i}>
{who ? <b>{who}:</b> : null}{lineText(entry)}
</li>;
})}
</ul>
{count > rows.length || this.state.expanded ?
<button type="button" className="forward-bundle-more" onClick={this.handleToggle}>
{this.state.expanded ? '收起' : `查看完整记录(共 ${count} 条)`}
</button> :
<div className="forward-bundle-count">共 {count} 条消息</div>}
{comment ? <div className="forward-bundle-comment">{comment}</div> : null}
</div>;
}
}
+6
View File
@@ -14,6 +14,12 @@ export default class MetaMessage extends React.PureComponent {
// A date badge.
content = <>{this.props.date}</>
bubbleClass += ' date';
} else if (this.props.deleted) {
// 撤回/删除占位。messages-view.jsx 对 msg.hi 的消息一直在传 deleted={true},但这里从来
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
bubbleClass += ' date';
}
if (!content) {
+491 -14
View File
@@ -1,7 +1,7 @@
// Send message form.
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import { Drafty, TheCard } from 'tinode-sdk';
// Lazy-loading AudioRecorder because it's quite large due to
// a dependency on webm-duration-fix.
@@ -10,6 +10,50 @@ const AudioRecorder = React.lazy(_ => import('./audio-recorder.jsx'));
import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js';
import { replyFormatter } from '../lib/formatters.js';
import { forwardFavorites, getFavorites, getFileUrl, getFriends, translateReason } from '../lib/im-api.js';
// 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点,
// 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的
// 语义,也是本项目其它地方唯一有本地存储依据能撑起来的一种:不存在一个真实的"收藏表情"后端概念)。
// 表情本身的具体清单——纠正一处之前的误判:参考图`表情包展开(发送按钮默认状态).png`/`表情包
// 发送页面.png`其实清楚画出了一套具体的经典"黄脸"贴图(微信/QQ经典表情那种视觉风格),不是没有
// 图片依据。只是这个项目没有对应的贴图素材资源,做不到跟截图完全一致的视觉,退而求其次选了一组
// 常见、覆盖面广的系统 Unicode 表情——这是素材缺口下的折中方案,不是"没有依据、随便选的"。
const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘',
'🥰', '😗', '😙', '😚', '🙂', '🤗', '🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
'🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜', '😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑',
'😲', '☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯', '😬', '😰', '😱',
'🥵', '🥶', '😳', '🤪', '😵', '😡', '😠', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🥳', '🥴', '🥺', '🤫',
'🤭', '🧐', '🤓', '😈', '👿', '👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '❤️', '🧡', '💛', '💚', '💙',
'💜', '🖤', '💔', '💕', '💯', '🔥', '🎉', '🎂', '🌹', '☀️', '🌙', '⭐'];
// 跟 content-library-view.jsx 的 TYPE_LABEL 是同一套值(那边是 view 层,这边是 widget,不方便
// 互相 import,就近各自维护一份小常量)。收藏摘要要优先读 snapshot.content.txt 这个结构化字段,
// 不能像最初那版一样直接展示 snapshot.text——那是整条 content 原样 JSON.stringify 的结果
// (例如 {"txt":"..."}),不是给人看的,之前联调时真被这个坑绊了一次才发现。
const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
function favoriteSummary(item) {
const content = item.snapshot && item.snapshot.content;
if (content && typeof content.txt === 'string' && content.txt) {
return content.txt;
}
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
}
const RECENT_EMOJI_KEY = 'im-recent-emoji';
const MAX_RECENT_EMOJI = 30;
function loadRecentEmoji() {
try {
const raw = JSON.parse(localStorage.getItem(RECENT_EMOJI_KEY) || '[]');
return Array.isArray(raw) ? raw : [];
} catch (_) {
return [];
}
}
function pushRecentEmoji(emoji) {
try {
const list = [emoji].concat(loadRecentEmoji().filter(e => e !== emoji)).slice(0, MAX_RECENT_EMOJI);
localStorage.setItem(RECENT_EMOJI_KEY, JSON.stringify(list));
} catch (_) { /* ignore */ }
}
const messages = defineMessages({
messaging_disabled: {
@@ -68,6 +112,18 @@ class SendMessage extends React.PureComponent {
message: '',
audioRec: false,
audioAvailable: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
// @提醒选择器:mentionQuery 非 null 时显示面板;mentionStart 是触发这次匹配的"@"在
// message 里的字符下标(不是 fmt/grapheme 下标,纯 JS 字符串下标,跟 slice 配对用)。
mentionQuery: null,
mentionResults: [],
mentionHighlight: 0,
emojiOpen: false,
emojiTab: 'emoji',
recentEmoji: loadRecentEmoji(),
attachOpen: false,
cardPicker: null,
favPicker: null,
audioCancelling: false,
};
// Timestamp when the previous key press was sent to the server.
@@ -83,6 +139,19 @@ class SendMessage extends React.PureComponent {
this.handleDropAttach = this.handleDropAttach.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.selectMention = this.selectMention.bind(this);
this.startHoldRecording = this.startHoldRecording.bind(this);
this.handleHoldMove = this.handleHoldMove.bind(this);
this.endHoldRecording = this.endHoldRecording.bind(this);
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
this.insertEmoji = this.insertEmoji.bind(this);
this.deleteLastChar = this.deleteLastChar.bind(this);
this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
this.openCardPicker = this.openCardPicker.bind(this);
this.openFavPicker = this.openFavPicker.bind(this);
this.sendCard = this.sendCard.bind(this);
this.sendFavorite = this.sendFavorite.bind(this);
this.formatReply = this.formatReply.bind(this);
}
@@ -103,6 +172,15 @@ class SendMessage extends React.PureComponent {
if (this.messageEditArea) {
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
}
if (this.mentionSearchTimer) {
window.clearTimeout(this.mentionSearchTimer);
}
window.removeEventListener('mousemove', this.handleHoldMove);
window.removeEventListener('mouseup', this.endHoldRecording);
window.removeEventListener('touchmove', this.handleHoldMove);
window.removeEventListener('touchend', this.endHoldRecording);
window.removeEventListener('blur', this.endHoldRecording);
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
}
componentDidUpdate(prevProps) {
@@ -118,7 +196,27 @@ class SendMessage extends React.PureComponent {
}
if (prevProps.topicName != this.props.topicName) {
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null});
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null, mentionQuery: null,
mentionResults: [], emojiOpen: false, attachOpen: false, cardPicker: null, favPicker: null});
} else if (!prevProps.disabled && this.props.disabled && this.state.audioRec) {
// isWriter/etc are real-time ACS bits (messages-view.jsx re-derives them from {pres}/{meta}),
// so `disabled` can flip to true mid-recording -- the other party blocking you, being removed
// from a group, or getting muted, all without the user releasing the hold-to-talk button first.
// Unmounting <AudioRecorder> here (rather than leaving it mounted under a now-hidden branch)
// is what actually stops the mic via its componentWillUnmount; without this the recording just
// sits there with `disabled` true and no surviving UI able to end it, and if `disabled` later
// reverts to false in the same topic, this stale `true` would remount a fresh <AudioRecorder>
// with no user gesture at all -- silently starting to record again on its own.
//
// By the time this runs, React has already committed the `disabled` render and unmounted
// <AudioRecorder> (its ref callback nulled this.audioRecorderInstance), so endHoldRecording()'s
// own handleDone/handleDelete call is a no-op here -- it's called anyway for the six
// mousemove/mouseup/touchmove/touchend/blur/visibilitychange listeners startHoldRecording
// registered on window/document, which endHoldRecording always removes unconditionally
// regardless of whether the recorder instance is still around. Skipping this left them
// dangling until the next real mouseup/touchend/blur/tab-switch happened to fire.
this.endHoldRecording();
this.setState({audioRec: false});
} else if (prevProps.initMessage != this.props.initMessage) {
const msg = this.props.initMessage || '';
this.setState({message: msg}, _ => {
@@ -211,6 +309,27 @@ class SendMessage extends React.PureComponent {
return;
}
if (this.state.mentionQuery != null) {
if (e.key === 'Escape') {
e.preventDefault();
this.setState({mentionQuery: null, mentionResults: []});
return;
}
if (this.state.mentionResults.length > 0 && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const delta = e.key === 'ArrowDown' ? 1 : -1;
const count = this.state.mentionResults.length;
this.setState({mentionHighlight: (this.state.mentionHighlight + delta + count) % count});
return;
}
if (e.key === 'Enter' && this.state.mentionResults.length > 0) {
e.preventDefault();
e.stopPropagation();
this.selectMention(this.state.mentionResults[this.state.mentionHighlight]);
return;
}
}
// Enter pressed: either send, or enter a new line.
if (e.key === 'Enter') {
if (this.props.sendOnEnter == 'plain') {
@@ -234,6 +353,10 @@ class SendMessage extends React.PureComponent {
handleMessageTyping(e) {
this.setState({message: e.target.value});
this.updateMentionState(e.target.value, e.target.selectionStart);
if (this.props.onMessageChange) {
this.props.onMessageChange(e.target.value);
}
if (this.props.onKeyPress) {
const now = new Date().getTime();
if (now - this.keypressTimestamp > KEYPRESS_DELAY) {
@@ -243,6 +366,68 @@ class SendMessage extends React.PureComponent {
}
}
// 4.2-4.3 @提醒:只在群聊里挂 onMentionSearch(messages-view.jsx 只给群话题传这个 prop)。
// 触发条件是"光标前最近一个'@','@'前面是空白或整段开头,'@'和光标之间没有空白"——这样
// "联系xx@xx.com"这种邮箱场景里的@不会被误当成提醒触发。
updateMentionState(text, caret) {
if (!this.props.onMentionSearch || caret == null) {
return;
}
const before = text.slice(0, caret);
const match = /(?:^|\s)@([^\s@]*)$/.exec(before);
if (!match) {
if (this.state.mentionQuery != null) {
this.setState({mentionQuery: null, mentionResults: []});
}
return;
}
const query = match[1];
const start = caret - query.length - 1;
this.setState({mentionQuery: query, mentionStart: start, mentionHighlight: 0});
if (this.mentionSearchTimer) {
window.clearTimeout(this.mentionSearchTimer);
}
this.mentionSearchTimer = window.setTimeout(() => {
this.props.onMentionSearch(query).then(results => {
// mentionQuery may have moved on (user kept typing, or closed the picker) by the time this
// resolves -- only apply it if we're still showing a picker at all, matching the same
// stale-response guard used everywhere else responses race state.
if (this.state.mentionQuery != null) {
this.setState({mentionResults: results || []});
}
}).catch(() => {});
}, 200);
}
selectMention(item) {
const {message, mentionStart, mentionQuery} = this.state;
if (mentionStart == null || mentionQuery == null) {
return;
}
const name = item.group_nickname || item.display_name || item.user_id;
const insertion = '@' + name + ' ';
const end = mentionStart + 1 + mentionQuery.length;
const newMessage = message.slice(0, mentionStart) + insertion + message.slice(end);
const caretPos = mentionStart + insertion.length;
this.setState({message: newMessage, mentionQuery: null, mentionResults: []});
if (this.props.onMentionSelected) {
this.props.onMentionSelected(item.user_id, name);
}
if (this.props.onMessageChange) {
this.props.onMessageChange(newMessage);
}
// Textarea's own DOM value needs the same update -- setState alone re-renders with the right
// `value` prop, but the caret placement below needs the element's real value/selection to
// already match, and doing it in the same tick (rather than waiting on the render) avoids a
// visible flash of the stale text with the caret in the wrong place.
requestAnimationFrame(() => {
if (this.messageEditArea) {
this.messageEditArea.focus();
this.messageEditArea.setSelectionRange(caretPos, caretPos);
}
});
}
handleQuoteClick(e) {
e.preventDefault();
e.stopPropagation();
@@ -252,6 +437,198 @@ class SendMessage extends React.PureComponent {
}
}
// 组合面板图标切换规律(核对图片确认):表情/附件两个入口固定在输入框右侧;哪个面板当前
// 展开,对应的那个图标就变成"⌨"(点击收起换回键盘),另一个保持原样不变——不是两个一起变。
// 这里只做了表情这一侧,附件面板(4.13/4.9-4.14)另外接线时要遵守同一条规则,不要各自发明。
toggleEmojiPanel(e) {
e.preventDefault();
this.setState(s => ({emojiOpen: !s.emojiOpen, attachOpen: false, audioRec: false}));
}
insertEmoji(emoji) {
const ta = this.messageEditArea;
const start = ta ? ta.selectionStart : this.state.message.length;
const end = ta ? ta.selectionEnd : this.state.message.length;
const newMessage = this.state.message.slice(0, start) + emoji + this.state.message.slice(end);
pushRecentEmoji(emoji);
this.setState({message: newMessage, recentEmoji: loadRecentEmoji()});
if (this.props.onMessageChange) {
this.props.onMessageChange(newMessage);
}
const caretPosEmoji = start + emoji.length;
requestAnimationFrame(() => {
if (this.messageEditArea) {
this.messageEditArea.focus();
this.messageEditArea.setSelectionRange(caretPosEmoji, caretPosEmoji);
}
});
}
// 参考图的表情面板自带一个退格键,让"只点表情不碰键盘"这条路径也能删掉打错的字符——用
// Array.from 而不是 slice(0,-1) 切最后一个 Unicode 码点,避免把一个占两个UTF-16码元的emoji
// (比如复合肤色/家庭类emoji)齐腰斩断,删出半个乱码字符。
deleteLastChar() {
const ta = this.messageEditArea;
const start = ta ? ta.selectionStart : this.state.message.length;
const end = ta ? ta.selectionEnd : this.state.message.length;
let newMessage, caretPos;
if (start !== end) {
newMessage = this.state.message.slice(0, start) + this.state.message.slice(end);
caretPos = start;
} else if (start > 0) {
const before = Array.from(this.state.message.slice(0, start));
before.pop();
newMessage = before.join('') + this.state.message.slice(start);
caretPos = before.join('').length;
} else {
return;
}
this.setState({message: newMessage});
if (this.props.onMessageChange) {
this.props.onMessageChange(newMessage);
}
requestAnimationFrame(() => {
if (this.messageEditArea) {
this.messageEditArea.focus();
this.messageEditArea.setSelectionRange(caretPos, caretPos);
}
});
}
toggleAttachPanel(e) {
e.preventDefault();
this.setState(s => ({attachOpen: !s.attachOpen, emojiOpen: false}));
}
// 3.12/4.9-4.14 发名片:选一个好友,生成一张只含姓名+头像+该好友 uid 的名片消息发到当前会话。
// uid 必须用 TheCard.setTinodeID 写进 comm 数组(vCard 的"tinode"协议项),不是随便塞进某个
// 字段——widgets/the-card-mini.jsx 点开这张卡片时用 TheCard.getFirstTinodeID(card) 读这个
// 协议项来判断"发消息"按钮该指向谁,写错地方那个按钮会永远不出现。
openCardPicker(e) {
e.preventDefault();
this.setState({attachOpen: false, cardPicker: {loading: true}});
getFriends().then(data => {
this.setState({cardPicker: {loading: false, items: data.items || []}});
}).catch(() => this.setState({cardPicker: {loading: false, items: [], error: true}}));
}
sendCard(item) {
this.setState({cardPicker: null});
const build = avatarUrl => {
const card = new TheCard(item.display_name || item.user_id, avatarUrl);
TheCard.setTinodeID(card, item.user_id);
// Every other attachment sender (image/video/file/voice) threads a typeHint through to
// messages-view.jsx's sendMessage(msg, uploadCompletionPromise, uploader, typeHint) so the
// backend's imMessageType() classifier -- which only ever looks at head['x-im-type'] or a flat
// content.file_id/url, never at real Drafty entities -- doesn't fall through to its "text"
// default. This call was only ever passing the first argument, so every card message has been
// silently misclassified as plain text (confirmed live: search/history summarize it as raw
// Drafty JSON instead of a name).
this.props.onSendMessage(Drafty.appendTheCard(null, card), undefined, undefined, 'card');
};
if (item.avatar_file_id) {
getFileUrl(item.avatar_file_id).then(build).catch(() => build(undefined));
} else {
build(undefined);
}
}
// 收藏发送:走的是 Ch.7 已有的 forwardFavorites REST 接口(转发收藏的同一个接口,目标只给
// 当前会话一个),不经过 onSendMessage/Drafty——收藏本来就是后端已经存好快照的独立资源,
// 不是本地新拼一条消息。
openFavPicker(e) {
e.preventDefault();
this.setState({attachOpen: false, favPicker: {loading: true}});
getFavorites({type: 'all'}).then(data => {
this.setState({favPicker: {loading: false, items: data.items || []}});
}).catch(() => this.setState({favPicker: {loading: false, items: [], error: true}}));
}
sendFavorite(item) {
this.setState({favPicker: null});
if (!this.props.topicName) {
return;
}
// 这个接口恒定 200,真实成败要看 items[].status(跟批量收藏/批量转发是同一套设计,全局代码
// 审查那一轮已经在其它几处调用点踩过同一个坑并修过——这是新写的这处,同样的坑要一起避开,
// 不能只 .catch 网络层错误就当万事大吉。联调时真的用这条路径测出过 200 OK 但
// items[0].status:"failed"、reason:"PERMISSION_DENIED" 的情况(对方是真实好友、这个会话
// 本身正常能发消息),看起来是后端这个接口自己的权限判断有问题,不是本地能修的,如实报错
// 而不是假装发送成功。
forwardFavorites([item.favorite_id], [this.props.topicName]).then(result => {
const failed = (result && result.items || []).filter(r => r.status === 'failed');
if (failed.length && this.props.onError) {
this.props.onError('发送失败:' + (translateReason(failed[0].reason) || failed[0].reason || '未知原因'), 'err');
}
}).catch(err => {
if (this.props.onError) {
this.props.onError((err && err.message) || '发送失败', 'err');
}
});
}
// 4.7-4.9 按住说话 + 上滑取消:mousedown/touchstart 直接挂载 AudioRecorder(挂载即开始录音,
// 这一步跟原来点一下进入录音模式完全一样,没有改 AudioRecorder 自己的录音逻辑),松手时通过
// ref 直接调它的 handleDone/handleDelete——这两个方法本来是绑给它自己按钮的 onClick,要求一个
// 带 preventDefault 的事件对象,这里传一个最小的假事件满足这个要求,除此之外没有更深地伸手进
// 它的内部状态。上滑距离阈值 80px 是参照大多数同类应用(微信等)的常见取值,没有对应的可验证
// 数值来源,明确记录不是抄的哪个具体规格。
startHoldRecording(e) {
e.preventDefault();
if (this.state.audioRec) {
return;
}
this.holdStartY = 'touches' in e ? (e.touches[0] && e.touches[0].clientY) : e.clientY;
this.setState({audioRec: true, audioCancelling: false, emojiOpen: false, attachOpen: false});
window.addEventListener('mousemove', this.handleHoldMove);
window.addEventListener('mouseup', this.endHoldRecording);
window.addEventListener('touchmove', this.handleHoldMove, {passive: false});
window.addEventListener('touchend', this.endHoldRecording);
// mouseup/touchend only fire for a release point still inside this window/tab -- dragging the
// pointer out of the browser entirely, switching tabs/apps, or a permission prompt stealing
// focus (e.g. the very getUserMedia prompt this recording itself triggered, on a second call)
// all leave the mic running with no further event this listener pair will ever see. Ending the
// recording (same finish-or-cancel logic as a normal release, per the cancel-zone check) on
// either signal is a safer default than waiting indefinitely for a mouseup that may never come.
window.addEventListener('blur', this.endHoldRecording);
document.addEventListener('visibilitychange', this.handleHoldVisibilityChange);
}
handleHoldVisibilityChange() {
if (document.hidden) {
this.endHoldRecording();
}
}
handleHoldMove(e) {
const y = 'touches' in e ? (e.touches[0] && e.touches[0].clientY) : e.clientY;
if (y == null || this.holdStartY == null) {
return;
}
const cancelling = (this.holdStartY - y) > 80;
if (cancelling !== this.state.audioCancelling) {
this.setState({audioCancelling: cancelling});
}
}
endHoldRecording() {
window.removeEventListener('mousemove', this.handleHoldMove);
window.removeEventListener('mouseup', this.endHoldRecording);
window.removeEventListener('touchmove', this.handleHoldMove);
window.removeEventListener('touchend', this.endHoldRecording);
window.removeEventListener('blur', this.endHoldRecording);
document.removeEventListener('visibilitychange', this.handleHoldVisibilityChange);
this.holdStartY = null;
const fakeEvent = {preventDefault: () => {}};
if (this.audioRecorderInstance) {
if (this.state.audioCancelling) {
this.audioRecorderInstance.handleDelete(fakeEvent);
} else {
this.audioRecorderInstance.handleDone(fakeEvent);
}
}
}
render() {
const { formatMessage } = this.props.intl;
const prompt = this.props.disabled ?
@@ -271,29 +648,34 @@ class SendMessage extends React.PureComponent {
{this.state.quote}
</div>) : null;
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
const mentionPanel = this.state.mentionQuery != null ?
(<div id="mention-picker">
{this.state.mentionResults.length === 0 ?
<div className="mention-picker-empty">无匹配成员</div> :
this.state.mentionResults.map((item, i) => (
<div key={item.user_id} className={'mention-picker-row' + (i === this.state.mentionHighlight ? ' active' : '')}
onMouseDown={e => { e.preventDefault(); this.selectMention(item); }}
onMouseEnter={() => this.setState({mentionHighlight: i})}>
{item.group_nickname || item.display_name || item.user_id}
</div>
))}
</div>) : null;
return (
<div id="send-message-wrapper">
{!this.props.noInput ? quote : null}
{mentionPanel}
<div id="send-message-panel">
{!this.props.disabled ?
<>
{this.props.onAttachFile && !this.state.audioRec ?
<>
<a href="#" onClick={e => {e.preventDefault(); this.attachImage.click();}} title={formatMessage(messages.icon_title_add_image)}>
<i className="material-icons secondary">photo</i>
</a>
<a href="#" onClick={e => {e.preventDefault(); this.attachFile.click();}} title={formatMessage(messages.icon_title_attach_file)}>
<i className="material-icons secondary">attach_file</i>
</a>
</>
:
null}
{this.props.noInput ?
(quote || <div className="hr thin" />) :
(this.state.audioRec ?
(<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
description="Message shown when component is loading"/></div>}>
<AudioRecorder
ref={ref => {this.audioRecorderInstance = ref;}}
holdMode={true}
cancelling={this.state.audioCancelling}
onRecordingProgress={_ => this.props.onKeyPress(true)}
onDeleted={_ => this.setState({audioRec: false})}
onFinished={this.handleAttachAudio}/>
@@ -302,12 +684,23 @@ class SendMessage extends React.PureComponent {
value={this.state.message} onChange={this.handleMessageTyping}
onKeyDown={this.handleKeyPress}
ref={ref => {this.messageEditArea = ref;}} />)}
{/* 表情/附件两个入口固定在输入框右侧,哪个面板展开了对应图标就变⌨(点了收起换回键盘),
跟前面 toggleEmojiPanel 的规则说明是同一条约定,附件那侧照抄。 */}
{!this.props.noInput && !this.state.audioRec ?
<a href="#" onClick={this.toggleEmojiPanel} title="表情">
<i className="material-icons secondary">{this.state.emojiOpen ? 'keyboard' : 'insert_emoticon'}</i>
</a> : null}
{this.props.onAttachFile && !this.state.audioRec ?
<a href="#" onClick={this.toggleAttachPanel} title={formatMessage(messages.icon_title_attach_file)}>
<i className="material-icons secondary">{this.state.attachOpen ? 'keyboard' : 'add_circle_outline'}</i>
</a> : null}
{this.state.message || !audioEnabled ?
<a href="#" onClick={this.handleSend} title={formatMessage(messages.icon_title_send)}>
<i className="material-icons">{sendIcon}</i>
</a> :
!this.state.audioRec ?
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title={formatMessage(messages.icon_title_record_voice)}>
<a href="#" onMouseDown={this.startHoldRecording} onTouchStart={this.startHoldRecording}
onClick={e => e.preventDefault()} title={formatMessage(messages.icon_title_record_voice)}>
<i className="material-icons">mic</i>
</a> :
null
@@ -316,11 +709,95 @@ class SendMessage extends React.PureComponent {
onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={ref => {this.attachImage = ref}} accept="image/*, video/*"
onChange={this.handleAttachImage} style={{display: 'none'}} />
<input type="file" ref={ref => {this.attachCamera = ref}} accept="image/*" capture="environment"
onChange={this.handleAttachImage} style={{display: 'none'}} />
</>
:
<div id="writing-disabled">{prompt}</div>
}
</div>
{this.state.emojiOpen ?
<div id="emoji-panel">
<div id="emoji-panel-tabs">
<a href="#" className={this.state.emojiTab === 'emoji' ? 'active' : ''}
onClick={e => {e.preventDefault(); this.setState({emojiTab: 'emoji'});}}>😊</a>
<a href="#" className={this.state.emojiTab === 'recent' ? 'active' : ''}
onClick={e => {e.preventDefault(); this.setState({emojiTab: 'recent'});}}>♡</a>
</div>
<div id="emoji-panel-grid">
{(this.state.emojiTab === 'emoji' ? EMOJI_LIST : this.state.recentEmoji).length === 0 ?
<div className="emoji-panel-empty">还没有常用表情</div> :
(this.state.emojiTab === 'emoji' ? EMOJI_LIST : this.state.recentEmoji).map((emoji, i) => (
<a href="#" key={emoji + i} onClick={e => {e.preventDefault(); this.insertEmoji(emoji);}}>{emoji}</a>
))}
</div>
{/* 参考图表情网格下方那一整行退格/发送专属控件——原来完全没做,"只点表情不碰键盘"
这条路径下用户还得把视线移出面板去点外面的发送图标。复用已有的 sendIcon/handleSend,
不重新发明发送逻辑。 */}
<div id="emoji-panel-footer">
<a href="#" onClick={e => {e.preventDefault(); this.deleteLastChar();}} title="删除">
<i className="material-icons">backspace</i>
</a>
<a href="#" className={'emoji-panel-send' + (this.state.message ? ' active' : '')}
onClick={e => {e.preventDefault(); this.handleSend(e);}} title={formatMessage(messages.icon_title_send)}>
<i className="material-icons">{sendIcon}</i>
</a>
</div>
</div> : null}
{this.state.attachOpen ?
<div id="attach-panel">
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachImage.click();}}>
<i className="material-icons">photo</i><span>照片</span>
</a>
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachCamera.click();}}>
<i className="material-icons">photo_camera</i><span>拍摄</span>
</a>
<a href="#" className={this.props.canStartCall ? '' : 'disabled'}
onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); if (this.props.canStartCall && this.props.onStartAudioCall) this.props.onStartAudioCall();}}>
<i className="material-icons">call</i><span>语音通话</span>
</a>
<a href="#" onClick={this.openCardPicker}>
<i className="material-icons">badge</i><span>名片</span>
</a>
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachFile.click();}}>
<i className="material-icons">attach_file</i><span>文件</span>
</a>
<a href="#" onClick={this.openFavPicker}>
<i className="material-icons">star_border</i><span>收藏</span>
</a>
<a href="#" className={this.props.canStartCall ? '' : 'disabled'}
onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); if (this.props.canStartCall && this.props.onStartVideoCall) this.props.onStartVideoCall();}}>
<i className="material-icons">videocam</i><span>视频通话</span>
</a>
</div> : null}
{this.state.cardPicker ?
<div className="send-message-picker-backdrop" onClick={() => this.setState({cardPicker: null})}>
<section onClick={e => e.stopPropagation()}>
<header>选择联系人<a href="#" onClick={e => {e.preventDefault(); this.setState({cardPicker: null});}}><i className="material-icons">close</i></a></header>
<div className="send-message-picker-list">
{this.state.cardPicker.loading ? <p className="send-message-picker-empty">加载中…</p> :
this.state.cardPicker.items.length === 0 ? <p className="send-message-picker-empty">{this.state.cardPicker.error ? '加载失败' : '暂无好友'}</p> :
this.state.cardPicker.items.map(item => (
<a href="#" key={item.user_id} onClick={e => {e.preventDefault(); this.sendCard(item);}}>{item.display_name || item.user_id}</a>
))}
</div>
</section>
</div> : null}
{this.state.favPicker ?
<div className="send-message-picker-backdrop" onClick={() => this.setState({favPicker: null})}>
<section onClick={e => e.stopPropagation()}>
<header>我的收藏<a href="#" onClick={e => {e.preventDefault(); this.setState({favPicker: null});}}><i className="material-icons">close</i></a></header>
<div className="send-message-picker-list">
{this.state.favPicker.loading ? <p className="send-message-picker-empty">加载中…</p> :
this.state.favPicker.items.length === 0 ? <p className="send-message-picker-empty">{this.state.favPicker.error ? '加载失败' : '暂无收藏'}</p> :
this.state.favPicker.items.map(item => (
<a href="#" key={item.favorite_id} onClick={e => {e.preventDefault(); this.sendFavorite(item);}}>
{favoriteSummary(item)}
</a>
))}
</div>
</section>
</div> : null}
</div>
);
}
+1444 -338
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+34 -4
View File
@@ -110,6 +110,7 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
}
}
componentWillUnmount() {
this._unmounted = true;
this.startedOn = null;
if (this.stream) {
this.cleanUp();
@@ -139,7 +140,7 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
if (duration > _config_js__WEBPACK_IMPORTED_MODULE_6__.MAX_DURATION) {
this.startedOn = null;
this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn;
this.durationMillis = duration;
this.setState({
enabled: false,
recording: false,
@@ -205,11 +206,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 +228,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() {
@@ -234,6 +242,10 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
this.canvasHeight = this.canvasRef.current.height;
}
initMediaRecording(stream) {
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return;
}
this.stream = stream;
AUDIO_MIME_TYPES.some(mimeType => {
if (MediaRecorder.isTypeSupported(mimeType)) {
@@ -329,13 +341,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 +412,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
+76 -33
View File
@@ -31,6 +31,7 @@ CALL_ENDED_SOUND.loop = true;
const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted';
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
const UNMOUNTED_DURING_OFFER = Symbol('unmounted-during-offer');
function formatElapsedTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor(totalSeconds % 3600 / 60);
@@ -168,6 +169,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}
}
componentWillUnmount() {
this._unmounted = true;
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onInfo = this.previousOnInfo;
this.stop();
@@ -192,6 +194,9 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}
onInfo(info) {
if (info.what != 'call') {
if (this.previousOnInfo) {
this.previousOnInfo(info);
}
return;
}
switch (info.event) {
@@ -241,6 +246,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
return;
}
navigator.mediaDevices.getUserMedia(this.localStreamConstraints).then(stream => {
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return;
}
if (!this.localStreamConstraints.video) {
stream.addTrack(this.emptyVideoTrack());
}
@@ -493,6 +502,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
pc.setRemoteDescription(desc).then(_ => {
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
}).then(stream => {
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return Promise.reject(UNMOUNTED_DURING_OFFER);
}
let dummyVideo;
if (!this.localStreamConstraints.video) {
dummyVideo = this.emptyVideoTrack();
@@ -520,7 +533,11 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.setState({
callInitialSetupComplete: true
}, _ => this.drainRemoteIceCandidatesCache());
}).catch(this.handleGetUserMediaError);
}).catch(err => {
if (err !== UNMOUNTED_DURING_OFFER) {
this.handleGetUserMediaError(err);
}
});
}
handleRemoteHangup() {
if (!this.state.waitingForPeer) {
@@ -582,13 +599,19 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}).then(stream => {
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
if (this._unmounted) {
track.stop();
return;
}
return this.replaceLocalVideoTrack(track);
}).then(_ => {
this.sendOnDataChannel(VIDEO_UNMUTED_EVENT);
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
this.setState({
videoToggleInProgress: false
});
if (!this._unmounted) {
this.setState({
videoToggleInProgress: false
});
}
});
}
sendOnDataChannel(message) {
@@ -625,11 +648,17 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
if (this._unmounted) {
track.stop();
return null;
}
return this.replaceLocalVideoTrack(track);
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
this.setState({
videoToggleInProgress: false
});
if (!this._unmounted) {
this.setState({
videoToggleInProgress: false
});
}
});
});
}
@@ -668,10 +697,17 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
navigator.mediaDevices.enumerateDevices().then(devices => {
const outputs = devices.filter(d => d.kind == 'audiooutput');
const match = nextOn ? outputs.find(d => /speaker/i.test(d.label)) : outputs.find(d => /earpiece|receiver/i.test(d.label));
if (this._unmounted || !this.remoteRef.current) {
return null;
}
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
}).then(_ => this.setState({
speakerOn: nextOn
})).catch(err => console.error('Failed to switch audio output device', err));
}).then(_ => {
if (!this._unmounted) {
this.setState({
speakerOn: nextOn
});
}
}).catch(err => console.error('Failed to switch audio output device', err));
}
render() {
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
@@ -697,28 +733,32 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
const minimizedClass = this.props.minimized ? 'minimized' : null;
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
if (this.props.minimized && this.state.audioOnly) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: "minimized audio-minimized",
ref: this.containerRef,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "call"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
className: "call-pill-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "full-screen",
onClick: this.props.onToggleMinimize,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: fullScreen
}, void 0, false)
}, void 0, false)]
}, void 0, true)
}, void 0, false);
return ((0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: "minimized audio-minimized",
ref: this.containerRef,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "call"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
className: "call-pill-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "full-screen",
onClick: this.props.onToggleMinimize,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: fullScreen
}, void 0, false)
}, void 0, false)]
}, void 0, true)
}, void 0, false)
);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
@@ -775,7 +815,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "caller-name",
children: peerTitle
}, void 0, false)]
}, void 0, false), !connected ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
className: "caller-status",
children: "正在等待对方接受邀请…"
}, void 0, false) : null]
}, void 0, true)]
}, void 0, true)
}, void 0, false)]
File diff suppressed because one or more lines are too long