Compare commits

...
40 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 46a98782fd 修复"退出当前登录"仍用原生 window.confirm(),与其余三个确认弹层不一致
现象:清除缓存/清除聊天记录/注销账号三处早前已经统一改成组件内 ConfirmSheet 弹层
(本文件其它三处组件自己的注释也在讲这次统一),唯独"退出当前登录"还留着最原始的
window.confirm()。本轮测试发现这在当前浏览器自动化环境下会被判定为"环境不支持同步
弹窗"而直接返回 false(控制台可见"Page dialog suppressed"警告),退出登录点了完全
没反应,且没有任何错误提示——这不只是测试环境的偶然状况:不少内嵌 WebView/嵌入式
容器场景本身就会禁用 confirm()/alert() 这类阻塞式原生对话框,真实用户在这些环境下
点"退出登录"会遇到一模一样的静默无效。

修法:新增 LogoutConfirm 组件,复用同一个 ConfirmSheet(图标 logout,标题"退出登录"),
"退出当前登录"按钮改成 setSheet('logout') 打开这个弹层,与 CacheClear/
ClearHistoryConfirm/DeleteAccountConfirm 完全同构,不再依赖浏览器原生对话框。

验证:npm run build:dev 后在浏览器实测,点击"退出当前登录"正确弹出本地弹层(标题/
文案/确认取消按钮与设计一致,logout 图标正常渲染为图形而非文字),点"确认"后真实
退出登录、回到企业码入口页;点"取消"正确关闭弹层且不登出。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:19:45 +08:00
Your NameandClaude Sonnet 5 eaafabe94f 修复用户名+邀请码注册(无手机号)必现崩溃:Tinode.credential(null) 抛异常
现象:企业码注册流程走"仅用户名+邀请码,不填手机号"这条路径时,点"注册"必然抛出
原始 JS 报错"Cannot destructure property 'val' of 'meth' as it is null",直接
展示在错误提示条上,注册无法完成。

根因:auth-onboarding-view.jsx 的 Register 组件在没有手机号凭证、只有邀请码时,
把 cred_ 显式传成 null(没有可附加的凭证)。tinode-sdk 的 Tinode.credential(meth,...)
用 `typeof meth == 'object'` 判断参数是不是一个 {meth,val,resp,params} 对象,但
JS 里 `typeof null === 'object'` 也是 true,导致对 null 无条件解构,直接抛异常。
所有走"登录"分支的调用点传的从来都是"字段可能是 undefined 的对象",从未传过字面量
null,所以这个 bug 只在"用户名注册且没有手机号凭证"这一条路径上必现。

修法:cred_ 为空时不再调用 Tinode.credential(),直接把 cred 字段设为 undefined,
绕开 SDK 这个不处理 null 输入的缺陷,不改动 vendored 的 tinode-sdk 代码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:19:26 +08:00
Your NameandClaude Sonnet 5 fa2c6c2f07 修复会议详情页响应会议后toast文案"已已接受"/"已已拒绝"重复"已"字
MeetingDetail.respond()提交回应成功后用 `已${RESPONSE_LABEL[response]}` 拼toast文案,
但RESPONSE_LABEL的accepted/declined两项本身已经是"已接受"/"已拒绝"(给参与人列表
徽标用的),再拼一次"已"前缀就变成"已已接受"/"已已拒绝";tentative没有"已"前缀,
拼出来是"已待定",语义也不通顺。同页面另一个响应入口MeetingCard.respond()
早就手写了三条正确文案(已接受会议邀请/已拒绝会议邀请/已标记为待定),
两处本应一致却各写一套。

改为复用MeetingCard已有的正确文案,不再从RESPONSE_LABEL派生。

已用kcg051307发起、asn494180作为参会人的3场真实会议在浏览器逐一实测三种回应,
确认toast分别正确显示"已接受会议邀请"/"已拒绝会议邀请"/"已标记为待定"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:18:19 +08:00
Your NameandClaude Sonnet 5 db75aaa25b 修复会议详情页"参会确认"统计遗漏"待定"响应,三项数字之和对不上参会人数
MeetingDetail 的 counts 对象本就统计了 tentative({accepted,pending,declined,tentative}),
但渲染只画了已接受/待响应/已拒绝三项,待定响应被计算出来后从未显示。真实复现:
组织者建会议、参会人回应"待定"后查看会议详情,"参会确认"区域三个数字之和小于
participant_count,且没有任何说明缺的那一份去了哪。

补充第4个统计项(待定),保持原有三项顺序不变;base.css 的
.meeting-status > div 网格列数从3改4(.report-statistics 复用同一条规则,
拆开单独设置避免影响日报/月报团队统计的应提交/已提交/未提交三列布局)。

已用 asn494180(组织者)/kcg051307(参会人) 两个真实账号在浏览器实测:kcg回应"待定"后
version从1变为2,asn的会议详情页刷新后正确显示"1 已接受/0 待响应/0 已拒绝/1 待定",
截图确认4列布局无溢出/挤压。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:14:10 +08:00
Your Name 693265658f fix(导航): 关闭聊天/信息面板改为真实返回,不再新增历史记录
用户反馈:从会话列表打开聊天A,点页面自己的"返回"关掉聊天,再打开聊天B,这时候点
浏览器的返回键,会先回到列表,再点一次返回又跳回了聊天A——因为"关闭"这个动作和"打开"
一样都在往浏览器历史里push新记录,而不是弹出(pop)"打开"时push的那条,导致聊天A那条
记录一直遗留在历史栈里,多按一次返回就会被重新翻出来。

两处受影响、同一根因:
- tinode-web.jsx 的 handleHideMessagesView(移动端聊天页左上角"←",messages-view.jsx
  的#hide-message-view)——打开聊天(handleTopicSelected)是真实push,关闭时原来无条件
  再push一条"没有话题"的状态,现在改成先尝试HashNavigation.goBack(),找不到可回退的
  历史才退回原来的push方式。
- info-view.jsx 的 handleBackNavigate(旧版InfoView自己的返回/关闭按钮,目前只有"已
  保存的消息"这一个话题还在用它,好友/群的资料页已经在更早的章节改用真正修过返回逻辑
  的friend-settings/group-settings)——同样的问题,同样的修法。

已在浏览器验证:连续"开聊天A→点返回关闭→开聊天B"后,浏览器返回键能一次性回到聊天A
从未存在过一样,不会再被重新翻出来;InfoView的关闭按钮点击后hash是异步变化的(真实
history.back()的特征),不再是原来同步的push。
2026-09-06 12:29:56 +08:00
Your Name 4fc65b0f40 fix(日报): "提醒未提交成员"改用组织架构接口凑候选名单,不再必现400
后端问题清单"新增2"记录过:POST /reports/daily/reminders 要求调用方传入完整候选
user_ids 名单(服务端只做"从候选里筛出未交的人再提醒",不会自己算全公司名单),
但前端一直传空数组,必然命中服务端"user_ids为空"的400校验。

重新核实时发现这条其实不是后端缺口——GET /organization/members 不传 q/department_id
时就会返回调用者可见范围内的全部在职成员(分页),这个接口本来就在会议参会人选择器
里用着,只是"提醒未提交成员"这个按钮没有复用它。改为点击时先分页拉取全部组织成员
user_id,再传给提醒接口;组织架构为空时给出明确提示而不是让请求再次落入400。

已在浏览器验证请求格式(limit=100,与后端实际接受的参数一致)和空组织架构下的
兜底提示;当前测试租户组织架构数据本身为空,未能覆盖"真的有人被提醒"这条路径,
但接口契约已对照 im_be 源码逐字段核实。
2026-09-06 12:07:24 +08:00
Your Name 623f7dc40f fix(导航): 排查全项目后修复其余同类tab切换污染历史记录问题
上一提交(e61eb402)只修了"我的收藏"/"查找聊天记录"两处,用户要求排查其他页面是否
也有同样问题。全项目排查<nav>/tabbar/active态切换元素,逐一确认onClick是否直接调用
HashNavigation.navigateTo()(每次都push一条历史记录),找到5处同类实例并修复:

- message-center-view.jsx / workplace-collaboration-view.jsx:底部导航"工作台"<->"消息"
  两个tab——这是全应用唯一的根级tab bar,也是点击频率最高的一处,之前同样每次切换都
  push一条历史记录。
- workplace-collaboration-view.jsx:日报/月报列表页的"我的xx"<->"团队统计"切换(同一个
  组件、同一个页面,只是team boolean不同),同样的问题。
- new-topic-view.jsx:"开始新会话"页的搜索/新群组/通过id三个tab。
- wallpapers.jsx:"壁纸"设置页的图案/图片两个tab。

均改用上一提交新增的HashNavigation.navigateReplace()。已在浏览器逐一实测:连续切换
多次tab后,无论真实浏览器返回还是应用自己的返回按钮,都能一次性返回到进入该页面之前
的真实上一页。

以下经排查确认不受影响,未改动:group-management-view.jsx的群聊分类tab(我创建的/
管理的/加入的)和contact-management-view.jsx的字母索引锚点均只用本地state/scrollIntoView,
不涉及hash跳转;auth-onboarding-view.jsx的登录<->注册切换、new-topic-view.jsx提交后的
三处跳转是真实的"离开当前页"操作,保留push语义符合预期。
2026-09-06 11:16:01 +08:00
Your Name e61eb40250 fix(收藏/聊天记录检索): 切换页内tab不再污染浏览器历史记录
用户反馈"我的收藏"页面点击tabs切换(全部/图片/视频/文件/语音/文本/链接)会记录到浏览器
历史记录里,导致点击返回一直在这个页面内切换tab,而不是真正返回上一页。

根因:content-library-view.jsx的Favorites/HistoryNav每次切tab都调用navigate(),
即HashNavigation.navigateTo() -> location.hash = ...,每次赋值都会被浏览器记为一条
新的历史记录。切N次tab就push N条记录,无论是系统返回键还是应用自己的"返回"按钮
(HashNavigation.goBack()),都要先把这N条tab切换记录走完才能真正离开页面。

修复:新增HashNavigation.navigateReplace(),用history.replaceState替换当前历史记录
而不是新增一条(hash该更新还是更新,刷新/复制链接不受影响),并手动派发hashchange事件
供tinode-web.jsx现有的监听器捕获——replaceState本身不会自动触发该事件。Favorites的
TYPE_LABELS tabs和HistoryNav("查找聊天记录"页的聊天记录/图片/视频/文件/链接tabs)
两处tab切换都改用这个新方法,不再计入navigationCount。

已在浏览器实测两处:连续切3次tab后,无论是原生history.back()还是应用返回按钮,都能
一次性返回到进入这个页面之前的真实上一页,不再卡在tab切换历史里。
2026-09-06 10:45:24 +08:00
Your Name 01325ad009 fix(css): 补齐按钮内标题/内容间距异常的遗漏实例
用户反馈"我的收藏-文件"tab标题(strong)与内容(small)间距过大,根因是全局
`button{line-height:2.2rem}`重置(面向单行居中按钮文案)继承进了改造成
卡片/多行行容器的<button>内部未显式覆盖line-height的<strong>/<small>。

本次除修复用户直接指出的.library-file-card/.library-record-header外,
系统排查全项目同类模式(button内嵌套strong+small),额外修复3处此前遗漏:
- .library-message-results(查找聊天记录-聊天记录tab搜索结果)
- .personal-identity("我"页头像+昵称+企业名身份卡片)
- .report-cards > button(月报列表行,日报列表用article非button故不受影响)

均已在浏览器实测line-height从2.2rem降至1.3,视觉间距恢复正常。
2026-09-06 10:40:40 +08:00
Your NameandClaude Sonnet 5 150db2fc02 重新构建dev产物(消息首页会话预览修复)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:16:55 +08:00
Your NameandClaude Sonnet 5 2ee8bac6a0 修复消息首页会话预览恒为"暂无消息",即使会话有真实历史消息
message-center-view.jsx 的预览文字读 topic.latestMessage(),这个方法
只会从话题自己的本地消息缓存里取值——而这个缓存只有在话题真正被
subscribe 过一次数据拉取后才会填充。me.contacts()(resetContactList)
给出的是每个联系人真实的、已缓存的Topic实例,包括用户这次会话里
从未打开过的——这些话题的latestMessage()恒为undefined,即使同一个
对象自己的touched/seq字段能证明服务端真实存在历史消息。

新增prefetchLatestMessagePreviews():对chatList里seq>0但本地缓存为空
的话题,一次性拉取最新1条消息填充预览。用一个Set跟踪已尝试过的话题,
避免resetContactList()(几乎每个{meta}/{pres}事件都会触发一次)反复
重新拉取同一个话题。

调试过程中发现并修正了自己犯的两个错误:(1) resetContactList()在
websocket握手完成前就会被本地持久化缓存恢复触发一次,此时的subscribe
必然503失败,加了isConnected()判断跳过,避免过早失败把话题永久标记
为"已尝试";(2) Topic.subscribe(getParams, setParams)的参数顺序搞反,
把查询对象传成了第二个参数(setParams),导致subscribe成功但发出的
{sub}包完全没有get子句,从未真正请求过消息数据。

已用真实浏览器验证:好友kcg051307会话(真实71条历史消息)刷新页面
后预览从"暂无消息"变为真实的最新消息内容,未读角标数字不受影响
(确认没有误将未读消息标记为已读)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:16:48 +08:00
Your NameandClaude Sonnet 5 ca71667975 重新构建dev产物(收藏文本折叠/搜索高亮、跳转定位消息两处修复)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:31:00 +08:00
Your NameandClaude Sonnet 5 68044fa1bb 修复"查找聊天记录"跳转定位到原消息在全部入口100%失效
componentDidUpdate里的jumpToSeq检查只在这个值发生变化时触发,但
tinode-web.jsx从不会同时渲染MessagesView和第7章侧边栏(!chapterSevenActive
门禁),所以从History的搜索结果/图片宫格/视频宫格跳转,每次都是卸载
第7章视图、挂载一个"首次渲染就已经带着jumpToSeq"的全新MessagesView
实例——componentDidUpdate根本不会跑(它不处理首次挂载)。7.12/7.15/
7.16全部"定位到消息来源"入口100%失效:jumpseq参数永久留在URL里,
会话固定停在最底部。

修复过程中还发现一个真实的竞态:首次挂载意味着componentSetup()刚刚
才发起topic订阅(异步、fire-and-forget),jumpToMessage自己的
topic.getMeta()调用要求订阅已建立,立即调用会抢在订阅完成前——实测
这个抢跑的getMeta()调用真实收到了服务端 {"code":403,"text":"permission
denied"} 拒绝(不是本地兜底,是真实的服务端往返),而真正的订阅请求
当时还在路上。改为轮询已有的isSubscribed()标志(而不是猜一个固定延迟
或改造subscribe()暴露其内部promise),封顶5秒,真正卡住的订阅仍会
落到jumpToMessage自己已有的错误处理上。

第十二轮最小粒度测试 Ch7 发现,已用真实浏览器验证:从工作台等其它
页面跳转过来时,消息正确加载、jumpseq参数正确从URL清除。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:30:53 +08:00
Your NameandClaude Sonnet 5 6882103461 实现收藏文本长文本折叠/展开/复制、收藏搜索关键词高亮
需求7.4两处此前完全没接:文本收藏是不限高度的纯<p>,长文本不折叠、
没有展开/收起,也没有复制按钮;收藏搜索的query从未传到文本收藏这一
层,History那边已有的Highlight组件在Favorites里从未被引用(实测:
搜索一个真实命中的关键词,结果列表0个<mark>)。

新增FavoriteText组件:用scrollHeight/clientHeight判断是否真的溢出
(避免给短文本也显示无用的"展开"按钮),复制走和context-menu.jsx
的copyMessageText一致的navigator.clipboard.writeText。

-webkit-line-clamp在当前环境对这个<p>不生效(getComputedStyle能读到
设置值但视觉不裁切),改用line-height反推的max-height+overflow:hidden;
外层容器不能用display:flex,会被UA blockify成flow-root导致同样失效。

第十二轮最小粒度测试 Ch7 发现,已用真实浏览器验证展开/收起切换正常、
搜索关键词高亮正确命中。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:30:40 +08:00
Your NameandClaude Sonnet 5 cccd21aff1 修复通讯录字母索引条位置错误,与好友列表重叠
.people-index 用了固定 top:13rem,是"新朋友/群组/文件助手/系统消息"
四行快捷入口成为现在这个样式之前遗留的旧值——实测索引条渲染在约173px
处,而按字母分组的好友列表实际从约375px才开始,索引条整整偏高200px,
盖在快捷入口行上而不是贴着好友列表。按真实布局(header+搜索框+四行
入口+分组标题外边距)重新算出27.5rem。

第十二轮最小粒度测试 Ch5-6 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:13:12 +08:00
Your NameandClaude Sonnet 5 5b836d50b2 修复通话发起后立即退出登录导致应用整体白屏崩溃
handleLogout()先断开WS连接、再重置界面状态,界面重置卸载
MessagesView时其componentWillUnmount会尝试发一条挂断信令,vendored
SDK的Connection.sendText()在已关闭的socket上同步抛错,componentWillUnmount
里没有error boundary兜底,导致整个应用被React卸载成白屏。

5处几乎相同的通话信令发送方法(ringing/hang-up/offer/ice-candidate/
answer)合并成一个sendCallSignal()辅助方法,发送前检查连接状态、
发送本身包一层try/catch。

第十二轮最小粒度测试 Ch8 发现(点击通话按钮后立即退出登录复现)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:09:23 +08:00
Your NameandClaude Sonnet 5 4a140e6c79 修复消息首页空状态误导文案、未登录完成时静默显示空列表
会话列表为空时统一显示"未找到相关会话"+搜索图标,即使用户根本没有
输入过搜索词——账号本身还没有任何会话时,也被说成"你的搜索没找到东
西"。现在区分:真正搜索无果才显示这句,否则显示"暂无会话"。

另外,myUserId尚未就绪(自动登录未完成/失败)但sidePanelSelected已
经是messages-home(比如收藏的旧链接、恢复的历史标签页)时,原来的
offline判断条件恒为false,页面会静默渲染一个和"账号确实是空的"没法
区分的空列表,用户以为账号没数据而不是还没登录成功。现在myUserId为
空时也按"未连接"处理,诚实展示"加载中"。

第十二轮最小粒度测试 Ch1-2 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:59:35 +08:00
Your NameandClaude Sonnet 5 8575c83980 修复举报原因列表文案裁切、举报详情页原因标签换行错位
两处grid都按"预留图标位"设计成3栏(2.7-2.8rem首列+内容+尾部),但
ReportReasons/ReportDetails实际渲染的都只是纯文字span,没有图标元素
——真实文案被挤进过窄的图标列:举报原因列表里"诈骗欺诈"等文案裁切成
看不懂的碎片(部分文字还叠在蓝底图标框上导致白底白字不可见);举报
详情页"举报原因"标签被挤到换行。改成按真实子元素数量分栏(2栏)。

第十二轮最小粒度测试 Ch10-11 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:58:36 +08:00
Your NameandClaude Sonnet 5 052cedb39b 重新构建dev产物(实名认证标题、会议响应按钮门禁两处修复)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:23:54 +08:00
Your NameandClaude Sonnet 5 22468edf6b 修复已取消/不可响应的会议列表卡片仍提供拒绝/接受按钮,点击后端403
MeetingCard对拒绝/接受按钮的渲染没有像MeetingDetail那样按
permissions.can_respond门禁,取消后的会议(my_response仍是
pending,属正常状态)会一直显示看似可用的按钮,点击后PUT
.../my-response正确返回403 PERMISSION_DENIED,用户点了却没反应。

第十二轮最小粒度测试 Ch9 发现,用真实取消会议(mtg_0w2TnfQiqaMwy8Bj)
复现(该agent进行中因会话限流中断,此项修复在中断前已完成,事后
确认无误后提交)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:23:48 +08:00
Your NameandClaude Sonnet 5 164a41270c 修复实名认证已认证状态显示臆造标题"认证信息"
参考图(实名认证.png / 实名认证-已实名.png)里两种状态标题文案完全
一致,都是"请您使用有效身份证信息认证"——原来按status分叉出的"认证
信息"这句是mock阶段从未核对过参考图的臆造文案。

第十二轮最小粒度测试 Ch1-2 发现(该agent进行中因会话限流中断,此项
修复在中断前已完成,事后确认无误后提交)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:23:44 +08:00
Your NameandClaude Sonnet 5 1e5e64ae2c 重新构建dev产物(撤回菜单误判、验证码文案、文本收藏撤回态三处修复)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:13:55 +08:00
Your NameandClaude Sonnet 5 b5531b0b7f 修复文本收藏/聊天记录检索不识别已撤回状态、P2P来源收藏名称解析404
问题13(收藏availability恒为available)后端修复后暴露的真实缺口:
text类型收藏和History的history-search结果都只判断item.type,从不看
availability,撤回的文本消息会原样一直显示旧内容(History里甚至会
显示后端messageText()对nil content兜底出的字面量"null")。

另外,P2P来源的收藏来源名称解析函数把复合p2p话题id当用户uid传给
/users/{id}/profile,必现404,永远显示原始话题字符串而非好友昵称;
改用已有的getConversationContext接口正确解析。

第十一轮最小粒度测试 Ch7 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:13:49 +08:00
Your NameandClaude Sonnet 5 117bf43c25 修复身份验证输错短信验证码时显示"独立密码不正确"
CODE_INVALID 这个reason同时被独立密码校验和短信验证码校验两个不相关
接口复用,错误文案只写了前者的措辞,在身份验证页答非所问。改成对两
种场景都准确的通用措辞。

第十一轮最小粒度测试 Ch10-11 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:13:40 +08:00
Your NameandClaude Sonnet 5 fc757cc043 修复消息发送失败/排队中时长按菜单仍提供"撤回"
判断只查 params.seq > 0,未排除 tinode-sdk 本地占位 seq(LOCAL_SEQID,
268435455,同样是正数)——发送失败/仍在排队的消息会被误判成"已持久化
的真实消息",点击撤回后端正确拒绝(无此消息),却给用户一个答非所问
的"请求内容有误"提示。

第十一轮最小粒度测试 Ch3-4 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:13:34 +08:00
Your NameandClaude Sonnet 5 89fdd31cd4 通讯录/新朋友/黑名单列表区分"加载失败"与"真的是空"
三处都用 .catch(() => {}) 吞掉网络错误,loading 结束后空数组直接落到
"暂无好友"/"暂无新朋友申请"/"黑名单为空",加载失败和真的没数据在界面
上无法区分。改成 group-management-view.jsx 里 11 处同类组件已经在用
的 loadError 状态模式。

第十轮最小粒度测试 Ch5-6 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:57:17 +08:00
Your NameandClaude Sonnet 5 4dbe76f2bd 重新构建dev产物(注册持久化登录、白屏崩溃、视频缩略图三处修复)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:49:13 +08:00
Your NameandClaude Sonnet 5 bab1f13e04 修复视频收藏/聊天记录缩略图恒为破损图片、时长换算错误
MediaThumb 把视频实体自身字节(.val/.ref,给 <video> 解码用)当图片
塞进 <img src>,浏览器无法解码,缩略图恒为损坏图标;改用后端已下发但
从未使用的 .preview/.premime 海报字段(lib/formatters.js 的
quoteImageOrVideo()/inlineVideoAttr() 早已用同一约定)。

时长换算:收藏页把毫秒当秒数传给 secondsToTime,2.4秒视频显示
"39:26";History 媒体宫格则完全没接上 duration 字段(mediaMessagePayload
是扁平结构,需要跟 history-files 一样重建 Drafty 实体才能取到)。

第十轮最小粒度测试 Ch7 发现(纠正第九轮"证据不足"的结论)。已用真实
收藏视频实测确认:缩略图正确解码(320x240/96x96),时长正确显示 0:02。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:49:04 +08:00
Your NameandClaude Sonnet 5 9d2db5a648 修复转发/切换话题时应用整体白屏崩溃
componentDidUpdate 的 initMessage 分支直接访问 this.messageEditArea
未判空,而输入框在 noInput(转发中)或 disabled(ACS未解析完)时会
被卸载——此时 initMessage(草稿/编辑内容)一变化就会解引用 null,
无 error boundary 兜底,整个应用白屏。

第十轮最小粒度测试 Ch3-4(实测复现2次)与 Ch8(同一处代码路径独立
命中)各自发现同一个bug。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:46:37 +08:00
Your NameandClaude Sonnet 5 35f659249e 修复注册流程不写入持久化登录态、注册失败500错误显示原始英文
Register组件缺少Login同款的keep-logged-in挂载副作用,导致注册产生的
新账号在下一次刷新时静默丢失会话(无报错、无跳转登录页)。
另外注册走原生WS {acc}协议而非REST,未接入CTRL_ERROR_TEXT_CN翻译表,
"internal error (500)"等错误会原样展示给用户。

第十轮最小粒度测试 Ch1-2 发现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:46:27 +08:00
Your NameandClaude Sonnet 5 eb4c1cd8da 用登录页现有logo重新生成PWA图标/favicon/OG分享图;顺手发现并重建了严重过期的生产构建产物
图标部分:logo.svg/logo96.png/logo192.png/logo32x32.png/logo32x32a.png原来画的是开源Tinode
项目自己的蓝绿橙"t"speech-bubble标志。用登录页AppMark组件的真实CSS参数(白色圆角方卡 + 旋转
-18deg的蓝色squircle + 加粗白色首字母,色值#1677ff,圆角50% 50% 50% 18%)原样重新画成独立
的SVG/PNG资源,不是另起一套新设计——文字/图标/圆角比例/旋转角度均按登录页实测的computed style
数值1:1还原。favicon(32x32)的"未读"变体(右下角绿点)沿用原有的visual language。

og-logo.jpeg(社交分享预览图,og:image meta标签用)问题比图标本身更明显:原图整个背景是金门大桥
照片,前景写着"Tinode / Open Source Instant Messenger",这两行文字是本次engagement里最直接的
一处Tinode品牌泄漏(没有隐藏在代码里,链接分享出去别人就直接看到)。改成深色渐变背景+同一套
logo mark+"MeetingIM"+"即时通讯",不再引用任何第三方项目的名字或素材。

意外发现:umd/index.prod.js(生产构建产物,index.html的<script>直接引用)从很早之前就没有
重新构建过,仍然包含"TinodeWeb"/"web.tinode.co"/tinode.co的隐私政策与服务条款链接/
support@tinode.co邮箱等一整套本轮及更早各轮已经在源码里修掉的Tinode字样——如果这份产物被
原样部署,之前所有品牌清理都不会真正体现在生产环境。已用`npm run build:prod`重新构建(干净
通过,只有两条跟本次改动无关的资源体积警告),确认重建后的产物里"MeetingIM"正确替换了旧品牌名、
真实域名替换了tinode.co。这次只重新生成了产物文件,没有执行任何部署动作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:30:11 +08:00
Your NameandClaude Sonnet 5 ee2cf6f965 修复收藏/聊天记录检索的请求竞态、下载页按钮占一半宽度、两个类名零CSS覆盖
- Favorites/History 组件的 load() 原来没有过期响应保护:切换类型/输入搜索词都会触发新请求,
  但如果一个更早发出的、范围更宽的请求(比如"全部")比后发的窄范围搜索请求更晚返回,会无条件
  覆盖掉已经正确显示的搜索结果——实测复现过"搜索框显示查询词,列表却是全量未过滤结果"。改成
  每次调用 load() 时认领一个自增计数器,响应到达时如果计数器已经被更新的调用推进过,直接丢弃
  这个过期响应。History组件(按topic/tab/query切换)是同一个模式,一并修复。
- `.library-download-actions`(下载页loading/done两态的按钮容器)是2列等宽grid,但两个状态
  永远只渲染1个按钮——按钮固定占左侧50%宽度,右侧约一半容器宽度是死空白。改成单列。
- `.library-empty-hint`(收藏/聊天记录/下载页的加载中提示文字)和`.library-load-more`
  ("加载更多"按钮)在base.css里完全没有对应规则,退化成浏览器默认段落间距/按钮外观——
  补了基础样式,跟第八轮修复的.library-link-text/.library-record-header是同一类遗漏。

均已重新构建并实测验证(下载按钮宽度、空状态文字样式、加载更多按钮样式均用真实DOM测量确认;
请求竞态修复通过真实收藏页搜索/清空往返验证无回归,竞态本身的精确时序难以在测试环境里稳定
复现,但修复采用的是标准、成熟的自增计数器丢弃过期响应模式)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:46:29 +08:00
Your NameandClaude Sonnet 5 38b43936ce 修复桌面端会话内错误提示完全不可见、收藏发送面板显示空白/裸类型名、delivery-marker图标className拼出undefined
- 桌面布局下打开一个会话时,sidepanel-view.jsx 走的是 messages-home 分支(渲染
  MessageCenterView),这个分支从来没有渲染过ErrorPanel;messages-view.jsx自己的ErrorPanel
  又只在displayMobile时才渲染——两边一凑,桌面端(最常见的使用状态)里任何onError(...)调用
  (上传失败、收藏转发失败、URL风控拦截等)全部没有可见的展示出口,用户只会觉得"点了但什么
  反应都没有"。已用真实handleError()调用实测确认:修复前DOM里根本没有.info-box节点,修复后
  正确渲染且visible。
- "我的收藏"发送面板:favoriteSummary()对图片/视频收藏显示空白(真实数据里这两类的
  snapshot.content.txt是占位用的单个空格,被直接当文本展示)、对文本类收藏显示裸露的内部
  类型字符串"text"(FAVORITE_TYPE_LABEL没有text这个key,且文本收藏的content本身就是纯字符串
  而非{txt:...}对象,原逻辑没处理这种形状)。改成图片/视频/语音三类固定用类型标签、文本类型
  优先判断content本身是不是纯字符串、并给FAVORITE_TYPE_LABEL补上text条目。
- received-marker.jsx的已发送/已送达图标(deliveryMarker对这两种状态返回的对象没有color字段)
  拼接className时把undefined直接字符串化拼了进去,实际渲染成"material-icons small undefined"。

均已重新构建验证;received-marker/favoriteSummary为纯函数级修复,逻辑经过仔细核对但未逐一
截图复核(不依赖运行时状态,风险低)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:42:08 +08:00
Your NameandClaude Sonnet 5 47148aa75d 修复登录页浏览器后退卡死、已认证态下登录/注册的凭据校验绕过、系统消息分页丢失等第1-2章问题
- 最主要的一处:登录成功后点浏览器"后退",会回到一个仍然认为"已登录"的底层连接、但重新挂载出来
  的登录表单——按钮永久卡在禁用的"登录中…"(loginDisabled只有失败分支会重置,成功分支从来没有,
  因为正常路径下这个页面早就导航走了,没人会看到);如果不管这个直接切换企业码后再次提交,
  doLogin()/handleNewAccountRequest()开头的"已认证就直接放行"逻辑会让登录悄悄跳过凭据校验,
  或者让注册请求收到后端原始英文错误"already authenticated (409)"。根治方式是在
  handleHashRoute加一道门:hash指向登录/注册等纯"未登录态"视图、但连接其实已认证时,直接重定向
  离开,不让这类表单有机会被重新渲染出来——已用真实浏览器验证4个入口(login/register/tenant/
  reset/cred)全部正确重定向,且确认不影响真正的登录后安全类页面(如identity-check)。
  handleLoginSuccessful也顺手补了loginDisabled:false兜底。
- 系统消息列表:后端`GET /system-notifications`明明返回`next_cursor`表示还有更多,前端从来没用过,
  未读50+条永远只能看到最近20条、永远没法真正清零。补了"加载更多"分页——已用真实账号(60+条历史
  通知)验证,点击后正确追加下一页、游标耗尽后正确不再显示按钮。
- 已处理(通过/拒绝)的好友申请通知,原来只要event_type以"friend.request"开头就显示"查看新朋友"
  按钮——不区分"待处理"和"已是历史结果",点了跳到待处理列表也找不到对应项。改成只在
  `friend.request.created`时显示,已用真实账号里同时存在created/accepted/rejected三种通知
  验证行为符合预期。
- 注册页四处不统一的占位品牌名(讯工捷迅/捷信密使/捷讯/即时通讯,没有一个是真实品牌名)与
  系统消息页脚"即时通讯安全团队",统一改用BASE_APP_NAME("MeetingIM")。
- config.js的KNOWN_HOSTS.hosted从web.tinode.co改为web.118im.com(这是WS连接失败时的最终
  兜底地址,指向无关第三方域名不只是品牌问题,是真实会连错服务器);host-name.js的
  detectServerAddress()原来只要hostname是localhost就无条件返回硬编码的localhost:6060,
  完全不管页面实际监听的端口——本项目自己的开发代理跑在8093,清缓存后首次连接必然先撞一次
  死连接,改成和非localhost分支一样用页面自己的真实端口。
- 实名认证提交成功后,姓名/证件号输入框原来会变成空白(后端成功响应不含masked_name/
  masked_id_number,组件切到只读态时又没有回退到用户自己刚输入的草稿值)——改成没有脱敏字段时
  回退显示用户本次会话里刚输入的原始值,不是留白。
- Firebase推送通知初始化失败原来复用登录/注册表单的错误提示通道,可能盖住一个同时发生的真实
  登录错误,或在用户还没操作登录表单时就先弹出一条无关提示——推送本身是锦上添花功能,改成只
  console记录+静默关闭开关,不再抢占登录页的错误展示位。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:36:03 +08:00
Your NameandClaude Sonnet 5 8b51fd3eb8 修复通讯录顶部入口箭头图标错位、群管理员列表移除后读到服务端过期缓存
- .people-entry-list > button 是4列grid,最后一列专留给箭头图标,但"新朋友"未处理申请数为0/
  "群组"/"文件助手"/"系统消息"这几个入口没有badge时,JSX只渲染3个真实子元素,grid自动布局
  把箭头挤进第3列——实测空出623px空白,图标紧贴文字,完全不是贴右对齐效果。改成显式
  grid-column:4 钉住箭头位置,不再依赖前面到底渲染了几个兄弟节点。
- AdminList移除管理员后紧接着的load()重新查询命中了服务端对这个查询形状的过期缓存,界面上
  刚移除的管理员会短暂"复活"。改成移除成功后直接用已确认生效的结果本地更新state+patch共享
  缓存,不再指望立即重新查询能反映刚发生的变更(这个缓存问题本身是后端"新增7"已记录的问题,
  重新查询本身没有办法绕过,前端这次做的是不依赖它)。

均已用真实DOM结构/computed style确认修复生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:23:19 +08:00
Your NameandClaude Sonnet 5 7db687602b 修复移动端语音通话胶囊被撑成方块、来电页认证徽章在长姓名截断时完全不可见
- #video-container.minimized 的移动端媒体查询规则(宽度100%/高度11rem/方角,本是给视频悬浮窗
  修的)和 #video-container.audio-minimized(语音胶囊自己的auto/auto/无阴影)特异度相同
  (1,1,0),前者写在文件更后面、源码顺序上赢了——语音通话点最小化后,移动端下胶囊被强行撑成
  贯穿屏幕顶部的方形透明条,胶囊本体和还原按钮挤在左上角。补 :not(.audio-minimized) 排除。
- 来电全屏页认证徽章原来和姓名文字共用同一个 white-space:nowrap+ellipsis 的<h1>,姓名够长
  触发省略号时,排在姓名后面的徽章(同一行内容里靠后的部分)跟着一起被截断出可视区域之外——
  不是"字体显示不全",是整个徽章都看不见。改成flex布局:姓名单独一个可截断的flex子项,
  徽章作为flex:none的兄弟节点,不参与省略号裁切,姓名截多短徽章都能跟在后面。

均已用真实DOM结构/getComputedStyle确认修复生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:19:58 +08:00
Your NameandClaude Sonnet 5 d573b38d10 第九轮(部分):修复日报徽章配色、彻底重做PIN定位方案、清理静态文件品牌残留
- 日报卡片"已提交/未提交"状态徽章补色:DailyReportCard用<article><header><em>结构,原选择器
  要求.report-cards直接子<button>,日报卡片永远命中不了、只落到不分状态的兜底样式;月报因为
  行本身就是<button>所以没受影响。详情页.report-author em同理补了区分色(原来恒为绿色,"未
  提交"用绿色在语义上很怪)。
- 青少年模式/独立密码设置的PIN输入定位改用结构性方案,不再靠猜一个rem偏移量:给
  .auth-pin-boxes/.auth-pin-input包一层.auth-pin-wrap,输入框inset:0精确覆盖方块的真实
  渲染框,不管外层页面有没有标题、padding多少都自动对齐。上一轮的补丁(.youth-password-gate
  .auth-pin-input{top:1.75rem})把重叠率从0%提到32.5%,本轮实测确认还是不够(方块下方
  67.5%的面积点不中),根治后4个采样点(四角+中心)全部精确命中输入框。过程中额外发现并
  修复了两个更隐蔽的层叠问题:全局input[type=text]的height:2.5rem特异度反超裸class、
  textarea/input共享的margin:0.25rem重置——不清掉这两个会让inset:0看起来生效了、实际尺寸/
  位置还是被悄悄改掉。
- .report-loading(举报原因页加载态)补基础样式,原来在base.css里零匹配,吃浏览器默认<p>样式。
- 清理静态文件里的Tinode品牌残留:index.html/index-dev.html的<meta>标签(application-name/
  description/og:*/canonical,此前只改了<title>,meta标签一直没跟上)、manifest.json(name/
  short_name/description改MeetingIM;prefer_related_applications+related_applications整个
  删掉——这两个字段会让Android/Chrome"添加到主屏幕"时引导用户去装无关的第三方开源Tinode App,
  不只是文案问题);删除.well-known/assetlinks.json(声明了无关第三方App对本域名的URL处理/
  登录凭据代理权限,属于遗留模板残留,无真实对应关系,直接移除而非编造虚假内容)。

manifest.json里的图标文件(logo96.png/logo192.png/logo.svg)本身画的就是Tinode的蓝绿橙"t"标志,
是真实的视觉品牌泄漏,但重新设计一套图标涉及视觉识别决策,本次未动,留给用户决定。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:17:48 +08:00
Your NameandClaude Sonnet 5 f688578714 清除缓存/清除聊天记录/注销账号确认弹层改为叠加显示,不再是整页路由跳转
这三个"确认"操作原来是独立的hash路由,点击后"设置"整个列表(含标题栏)连同Header一起卸载,
只剩ConfirmSheet的半透明蒙层盖在空白背景上——蒙层本来是设计成衬着被弹出的页面看的,但因为
底下真的没有渲染任何东西,视觉上跟跳转到一个新的空白页面没有区别。

改为SettingsList自己的本地state(sheet: null|'cache'|'history'|'delete'),确认层作为兄弟
节点叠加渲染,列表本身不卸载,不再经过hash导航——照抄group-management-view.jsx的OwnerSelect
(选择新群主)已经在用的正确模式。同时清理了tinode-web.jsx两处硬编码视图白名单里对应的
三个route名(不再是独立路由,无需保留)。

contact-management-view.jsx同类的删除好友/拉黑/清空聊天记录确认层核实过不受影响——它的
ConfirmSheet会重新渲染Settings页面作为背景,视觉上本来就是正确的。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 17:32:39 +08:00
Your NameandClaude Sonnet 5 d759f67b11 第八轮最小粒度测试:修复CSS选择器/JSX结构不匹配的一整类bug,清理Tinode品牌残留
第八轮改用"读CSS选择器+核对JSX结构"的方式复测全部11章,命中了7轮纯接口测试从未发现的一类
问题——选择器写对了但目标元素的实际DOM结构/className对不上,样式静默失效:

- 语音通话最小化胶囊按钮尺寸被同特异度规则覆盖;移动端悬浮通话窗口left/right/width三值同时
  生效导致过度约束;来电全屏页认证徽章/姓名标题缺少专属样式覆盖
- 我的收藏-语音卡片三列grid对应实际只有1个子元素,播放器被压缩到卡片左侧一角;链接/记录头部
  完全没有基础样式,退化成浏览器默认外观
- 多选模式下"收到的"消息气泡被flex auto-margin一起拖到最右侧;.avatar-box/#send-message-panel
  两处逗号断句错误导致后半句选择器丢失祖先限定
- 按住说话悬浮反馈渲染成内联胶囊,而参考图要求的居中悬浮卡片样式其实已经存在(挂在了另一个非
  生产demo组件上);批量转发确认框沿用了80%视口高度;audio-player.jsx的playback类点在canvas
  上而非包裹层,波形和计时器可能纵向堆叠
- "消息已撤回"占位符误拼了日期分隔条的class而非专属的deleted

品牌清理:浏览器标签页标题、未读数标题更新均硬编码"Tinode";LINK_CONTACT_US等三个链接常量
指向开源Tinode项目自己的域名(联系我们改为跟用户条款/隐私协议一致的toast,不再发起外部mailto)。

以及:空会话丢失.chat-box样式类、青少年模式PIN定位、若干收藏/文件类型展示细节。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 17:09:42 +08:00
Your NameandClaude Sonnet 5 5e173c55e8 修复日报/月报详情页返回目标写死,团队视角进入会被送回"我的"列表
DailyReportCard/月报列表的报告条目在 team=true(团队统计)和 team=false(我的日报/月报)两种
模式下渲染的是同一个组件、同一个"查看详情"跳转,但 DailyReportDetail/MonthlyReportDetail
的返回目标写死成 daily-report-mine/monthly-report-mine——管理者从"团队统计"点进某条下属的
报告,返回却被送到自己的"我的日报/月报"而不是刚才浏览的团队列表。跳转时带上 team 参数,
详情页据此选择正确的返回目标,和同文件里 DailyReportList/MonthlyReportList 自己的
`team ? 'x' : 'workbench'` 返回逻辑保持一致模式。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:20:04 +08:00
61 changed files with 2159 additions and 1128 deletions
-26
View File
@@ -1,26 +0,0 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroidx",
"sha256_cert_fingerprints":
["25:9A:71:EE:BD:2E:9F:D3:2D:64:1E:90:49:33:BD:F3:E3:42:BD:C4:DD:FA:BA:68:EE:D2:EB:76:65:33:DC:92"]
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "web",
"site": "https://web.tinode.co"
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroidx",
"sha256_cert_fingerprints":
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
}
}
]
+383 -110
View File
@@ -879,6 +879,13 @@
grid-template-columns: repeat(3, 1fr);
}
/* 参会确认 now shows 4 stats (已接受/待响应/已拒绝/待定, see workplace-collaboration-view.jsx's
MeetingDetail) -- .report-statistics (日报/月报团队统计的应提交/已提交/未提交) stays at 3, only
this selector needs the extra column. */
.meeting-status > div {
grid-template-columns: repeat(4, 1fr);
}
.meeting-status span,
.report-statistics > div span {
display: flex;
@@ -1099,6 +1106,13 @@
font-style: normal;
}
/* DailyReportCard renders <article><header><em>, not the <button> the two rules below (1196-1197)
assume -- so submitted/draft there always fell through to this rule's one fixed color, unable to
tell the two states apart (MonthlyReportList's own rows really are <button>s, so it never hit
this gap). Same colors as the button-based rules, just re-declared at matching structure. */
.report-cards article > header em.submitted { color: #28784f; background: #e5f5ec; }
.report-cards article > header em.draft { color: #ad6c11; background: #fff2dd; }
.report-cards article p {
display: -webkit-box;
margin: 0.8rem 0;
@@ -1181,6 +1195,14 @@
min-width: 0;
}
/* Same class of bug as .library-file-card above: this row is a <button> (月报列表), and the global
`button{line-height:2.2rem}` reset inherits into the stacked 月份/摘要 <strong>/<small> lines --
the article>header variant just below doesn't need this, it's a plain <article>, not a <button>. */
.report-cards > button strong,
.report-cards > button small {
line-height: 1.3;
}
.report-cards > button small {
margin-top: 0.25rem;
color: #858d97;
@@ -1258,7 +1280,12 @@
.report-author {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) auto;
/* Same dead-avatar-column issue .report-cards article > header had (see its comment) and was
fixed for -- DailyReportDetail/MonthlyReportDetail (the only two renderers of this class)
never render an <img> here either, just the name+badge span and the status <em>. The unused
leading 3rem column squeezed the name into a column narrow enough to wrap "员工" onto its own
line, while the status badge landed in what should've been the name's share of the row. */
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
}
@@ -1283,10 +1310,14 @@
}
.report-author em {
color: #2c8257;
font-style: normal;
}
/* Was one fixed color (green) regardless of report.status's actual value -- a "未提交" (not yet
submitted) badge showing the same green as "已提交" reads backwards. */
.report-author em.submitted { color: #28784f; }
.report-author em.draft { color: #ad6c11; }
.report-detail h2,
.report-detail h3,
.report-detail p {
@@ -2117,6 +2148,8 @@ input[type="email"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="date"],
input[type="time"],
textarea {
height: 2.5rem;
border-bottom: 1px solid var(--clr-input-border);
@@ -2128,6 +2161,8 @@ input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
input[type="date"],
input[type="time"],
textarea {
width: 100%;
}
@@ -2137,7 +2172,9 @@ input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="search"]:focus,
input[type="tel"]:focus {
input[type="tel"]:focus,
input[type="date"]:focus,
input[type="time"]:focus {
border-bottom: 1px solid var(--clr-input-border-focus);
box-shadow: 0 1px 0 var(--clr-input-shadow-focus);
}
@@ -3114,7 +3151,7 @@ img.channel {
}
/* Online-offline indicator positioned inside avatar-box */
.avatar-box .online, .offline {
.avatar-box .online, .avatar-box .offline {
position: absolute;
width: 0.75rem;
min-width: 0.75rem;
@@ -3582,9 +3619,15 @@ img.channel {
.select-checkbox.blue {
color: var(--clr-primary-accent);
}
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row regardless of
whether the message itself is left- or right-aligned (docs/images/多选-选中对话框.png). */
.chat-box li .select-checkbox {
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row for sent (.right)
messages, where it would otherwise cluster next to the bubble at the row's right edge along with
everything else justify-content:flex-end pulls there. Scoped to .right only -- applying it
unconditionally (as this rule originally did) claims 100% of the row's remaining space for the
auto margin regardless of alignment, which on a received (.left) message drags the avatar+bubble
that follow the checkbox all the way to the row's right edge instead of leaving them where
justify-content's default flex-start already puts them: immediately after the checkbox, at the
row's left edge (docs/images/多选-选中对话框.png). */
.chat-box li.right .select-checkbox {
margin-right: auto;
}
@@ -3637,12 +3680,29 @@ img.channel {
.batch-forward-confirm {
max-width: 24rem;
}
/* Also carries .forward-dialog for its base sheet chrome, but that class's own height:80% (right
for forward-dialog.jsx's long contact list) leaves this content-sized confirm sheet mostly empty
space below its buttons. Same (single-class) specificity as .forward-dialog itself, so a bare
.batch-forward-confirm{height:auto} would depend on which rule happens to come later in this
file to win -- the compound selector here beats it unconditionally instead. */
.forward-dialog.batch-forward-confirm {
height: auto;
}
.batch-forward-target {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
}
/* .contact-box > li (2969 above) is more specific than the bare .batch-forward-target class above,
so its padding and its avatar-reserving divider (the gradient's gap starts at 3.5rem, assuming an
avatar occupies that space) win by default -- this row has no avatar, so the divider ends up
looking like it starts partway across the row. Reasserted here at matching specificity with a
full-width divider instead. */
.contact-box > li.batch-forward-target {
padding: 0.6rem 0;
background: linear-gradient(to right, var(--clr-background-extra-light-gray), var(--clr-background-extra-light-gray)) 0 100% / 100% 1px no-repeat, var(--clr-bright);
}
/* 从只读 div 改成可输入的留言框后,要显式清掉 input 的原生边框/宽度,否则会缩成一个小方框。 */
.batch-forward-comment {
box-sizing: border-box;
@@ -3900,7 +3960,18 @@ img.channel {
font-size: 1.6rem;
}
#send-message-panel .material-icons.secondary, .material-icons.disabled {
#send-message-panel .material-icons.secondary {
color: var(--clr-text-tertiary)!important;
}
/* Split out from the rule above -- it was originally a second, unscoped clause on it (a copy-paste
slip, matching the same .avatar-box .online/.offline mistake elsewhere in this file), which in
practice is the only rule greying out letter-tile.jsx's generic (no-letter) deleted-topic icon
(`<i class="material-icons {color} disabled">`, no wrapper class of its own to hang a scoped rule
off of) -- letter-tile.jsx's *other* deleted-avatar case (.lettertile.disabled) has its own real
rule and was never affected. Kept as an explicit, separately-scoped rule instead of silently
dropping a behavior nothing else provides. */
i.material-icons.disabled {
color: var(--clr-text-tertiary)!important;
}
@@ -4221,7 +4292,7 @@ button.full-screen {
min-width: unset;
}
.minimized button.full-screen {
.minimized:not(.audio-minimized) button.full-screen {
top: 1rem;
left: 1rem;
width: 2rem;
@@ -4236,7 +4307,7 @@ button.full-screen .material-icons {
font-size: x-large;
}
.minimized button.full-screen .material-icons {
.minimized:not(.audio-minimized) button.full-screen .material-icons {
font-size: large;
}
@@ -4362,7 +4433,7 @@ video {
margin-top: 0.5rem;
}
.caller-card .trusted-badge {
.caller-card .trusted-badge, .call-peer-card h1 .trusted-badge {
display: inline-block;
}
@@ -5497,29 +5568,12 @@ ul.tabbar li.active > a {
margin-right: 0;
}
.audio-hold {
flex: 1;
justify-content: center;
gap: 0.6rem;
}
.audio-hold canvas {
/* .compose-record-overlay (8588 below) replaced this holdMode presentation -- see
audio-recorder.jsx's holdMode render for why. */
.compose-record-overlay 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;
height: 1.8rem;
margin-top: 0.4rem;
}
.audio .duration {
@@ -5550,7 +5604,11 @@ ul.tabbar li.active > a {
display: block;
}
.audio-player div.playback {
/* Targets the wrapper div around canvas.playback + .timer (audio-player.jsx's non-short render) --
was accidentally written as div.playback, but "playback" is the canvas's own class, not this
wrapper's (which had no class at all), so this never matched anything; canvas+timer fell back to
block-level stacking (timer on its own line below the waveform) instead of sitting side by side. */
.audio-player .playback-row {
display: flex;
align-items: center;
justify-content: center;
@@ -6763,6 +6821,20 @@ div.toast.show {
margin-top: 1.2rem;
}
/* .auth-pin-input used to be sized/positioned with hardcoded top/width/height values guessed to
match wherever .auth-pin-boxes happened to render on each page -- .auth-pin-content's <h1> and
.youth-password-gate's shorter top padding push the boxes to two different heights, so one
shared guessed `top` could only ever be right for one of them (confirmed live twice: the two
pages' real box positions differ by ~9.25rem, and a value tuned for one left the other's boxes
only ~32% overlapping the real input, still mostly unclickable). Wrapping both in their own
tightly-fitting, positioned container instead -- inset:0 on the input then always matches
.auth-pin-boxes' actual rendered box exactly, on any page, with no measured constant to keep
in sync. */
.auth-pin-wrap {
position: relative;
display: inline-block;
}
.auth-pin-boxes {
display: grid;
grid-template-columns: repeat(4, 4rem);
@@ -6780,12 +6852,21 @@ div.toast.show {
.auth-pin-input {
position: absolute;
top: 11rem;
width: 19rem;
height: 5rem;
inset: 0;
margin: 0;
opacity: 0;
}
/* input[type="text"]'s global reset (2131 above) sets a fixed height:2.5rem at higher specificity
(0,1,1) than a bare .auth-pin-input's (0,1,0) -- it was winning and pinning this input to 2.5rem
tall regardless of the boxes' actual height, undoing the inset:0 fix above for the vertical axis
(width happened to still come out right only because the reset's own width:100% agrees with this
element's actual need). Selector bumped to definitively outrank it, not just happen to. */
.auth-pin-wrap .auth-pin-input {
width: 100%;
height: 100%;
}
.auth-pin-content .auth-primary {
width: min(100%, 24rem);
margin-top: 3rem;
@@ -7056,6 +7137,7 @@ div.toast.show {
margin-top: 0.4rem;
overflow: hidden;
font-size: 0.78rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -7522,6 +7604,20 @@ div.toast.show {
box-shadow: 0 0.2rem 0.8rem rgba(37,49,65,0.04);
}
.system-notification-load-more {
display: block;
width: 100%;
margin: 0;
padding: 0.7rem;
border: 1px solid #eceef1;
border-radius: 0.5rem;
box-shadow: none;
color: #567;
background: #fff;
font-size: 0.85rem;
text-transform: none;
}
.system-notification-list article.read .system-notification-card {
opacity: 0.82;
}
@@ -7552,22 +7648,6 @@ div.toast.show {
line-height: 1.55;
}
.system-notification-card p button {
display: inline;
width: auto;
min-width: 0;
height: auto;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
box-shadow: none;
color: #1683f8;
background: transparent;
font: inherit;
text-transform: none;
}
.system-notification-card dl {
margin: 0;
}
@@ -9106,6 +9186,16 @@ div.toast.show {
text-transform: none;
}
/* The 4th column is reserved for the trailing chevron, but the <em> badge before it only renders
when item.badge is truthy (most of these rows never have one) -- grid auto-placement fills
columns left-to-right in DOM order regardless, so with no badge the chevron auto-lands in
column 3 instead of 4, leaving column 4 empty and the icon sitting well short of the row's
right edge. Pinned to column 4 explicitly so its position doesn't depend on how many siblings
happened to render before it. */
.people-entry-list > button > .material-icons:last-child {
grid-column: 4;
}
.people-entry-list em {
display: grid;
place-items: center;
@@ -9162,7 +9252,8 @@ div.toast.show {
.group-list button > span:nth-child(2),
.group-request-list article > div,
.group-admin-page article > span:nth-child(2),
.group-muted-list article > span:nth-child(2) {
.group-muted-list article > span:nth-child(2),
.group-member-picker > button > span:nth-child(2) {
display: flex;
min-width: 0;
flex-direction: column;
@@ -9205,7 +9296,14 @@ div.toast.show {
.people-index {
position: absolute;
z-index: 2;
top: 13rem;
/* Anchored against .people-page (the nearest positioned ancestor), which starts above the
header -- 13rem used to land ~200px above the actual friend list (overlapping the fixed
4-row entry list: 新朋友/群组/文件助手/系统消息), a leftover value from before that entry
list existed in its current form. Real layout above the letter-grouped list is header
(4.5rem) + search bar incl. margins (4.4rem) + entry list incl. bottom margin (4 x 4.3rem
rows + 0.8rem margin = 18rem) + "好友通讯录" section title's own top margin (0.6rem) =
27.5rem -- confirmed live via getBoundingClientRect against the real rendered page. */
top: 27.5rem;
right: 0.35rem;
display: flex;
gap: 0.2rem;
@@ -9353,15 +9451,6 @@ div.toast.show {
text-transform: none;
}
.people-search-result em {
padding: 0.4rem 0.8rem;
border-radius: 0.3rem;
color: #fff;
background: #398afa;
font-size: 0.78rem;
font-style: normal;
}
.people-qr-page main,
.group-qr-page main {
display: flex;
@@ -9684,8 +9773,12 @@ div.toast.show {
.people-settings section small,
.group-settings section small {
min-width: 0;
margin-left: auto;
overflow: hidden;
color: #949ba4;
text-overflow: ellipsis;
white-space: nowrap;
}
.people-switch,
@@ -10212,20 +10305,6 @@ div.toast.show {
line-height: 1.8;
}
.group-announcement-detail footer {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eceef1;
}
.group-announcement-detail footer > span {
display: flex;
flex-direction: column;
}
.group-announcement-detail > small {
display: block;
margin-top: 1rem;
@@ -10309,12 +10388,15 @@ div.toast.show {
flex: 0 0 3.6rem;
}
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开 */
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开。宽高本该跟上面那条规则
一致(3.6rem)——之前这里写成了3rem,两条规则特异性相同、这条排在后面,字面覆盖了 width/
height,但没盖到上面那条的 flex:0 0 3.6rem(这条完全没提 flex),容器又是 flex-direction:
column,主轴尺寸吃 flex-basis 不吃 height,实际渲成了 3rem 宽、3.6rem 高的长方形。 */
.group-member-tile-action {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
width: 3.6rem;
height: 3.6rem;
border-radius: 0.45rem;
color: #98a0aa;
background: #f0f2f5;
@@ -10569,11 +10651,6 @@ div.toast.show {
text-transform: none;
}
.group-member-picker > button > span:nth-child(2) {
display: flex;
flex-direction: column;
}
.group-check {
display: grid;
place-items: center;
@@ -10847,6 +10924,30 @@ div.toast.show {
flex: 1 1 auto;
}
/* Favorites/History/DownloadPage's loading/error placeholder text and Favorites/History's "加载
更多" pagination button -- both had zero rules anywhere in this file, falling back to a bare
<p>'s default margins and a plain <button>'s default outline respectively. */
.library-empty-hint {
padding: 1.5rem 1rem;
color: #9299a2;
font-size: 0.85rem;
text-align: center;
}
.library-load-more {
display: block;
width: min(100%, 52rem);
margin: 0 auto;
padding: 0.8rem;
border: 1px solid #eceef1;
border-radius: 0.4rem;
box-shadow: none;
color: #567;
background: #fff;
font-size: 0.85rem;
text-transform: none;
}
.library-favorite-item {
display: flex;
gap: 0.7rem;
@@ -10866,7 +10967,7 @@ div.toast.show {
.library-favorite-content > footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 2rem;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
@@ -10900,6 +11001,52 @@ div.toast.show {
line-height: 1.65;
}
/* 需求7.4"长文本折叠、展开和复制":clamped 限定默认显示高度,展开态(FavoriteText 的
expanded=true) 去掉这个 class 即可显示全文;library-text-actions 放"展开/收起"(仅溢出
时才渲染)和"复制"两个按钮,颜色沿用应用里其它文字按钮的强调色。
没有用 -webkit-line-clamp:实测这个环境里它对这个 <p> 不生效——getComputedStyle 能读到
display/-webkit-line-clamp/overflow 都是设置的值,但视觉上完全不裁切,scrollHeight 也跟
clientHeight 相等,是那套盒模型本身在当前渲染环境下没有实际生效,不是 CSS 写错了。改用
.library-text-content 已有的 line-height:1.65 反推的 max-height(5 行 = 5*1.65em)+
overflow:hidden,是普通块级盒子,不依赖任何厂商前缀的特殊渲染路径,行为可预期。 */
.library-text-content.clamped {
max-height: 8.25em;
overflow: hidden;
}
/* library-text-wrap 故意不用 display:flex——曾经这样写过,结果 .clamped 的 <p> 变成了 flex
item:flex/grid item 的 specified display 会被 UA 按 CSS Display 规范"blockify",实测这里的
-webkit-box 被换成了 flow-root(getComputedStyle 验证过),-webkit-line-clamp 整个失效,折叠
彻底不生效。改回普通块级 + margin 做间距,让 .clamped 保持自己声明的 -webkit-box 不被更外层
的 flex 容器改写。 */
.library-text-wrap {
display: block;
}
.library-text-actions {
display: flex;
align-items: center;
gap: 1.1rem;
margin-top: 0.35rem;
}
.library-text-actions button {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin: 0;
padding: 0;
border: 0;
box-shadow: none;
background: transparent;
color: #3b82c4;
font-size: 0.82rem;
}
.library-text-actions button .material-icons {
font-size: 1rem;
}
.library-file-card {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) auto;
@@ -10935,6 +11082,13 @@ div.toast.show {
display: flex;
min-width: 0;
flex-direction: column;
/* .library-file-card is a <button>, and the global `button{line-height:2.2rem}` reset (meant for
a single line of centered action-button text) inherits straight down into this <strong>/<small>
pair -- getComputedStyle confirmed the <strong> line box is 29.33px tall for 14px text, so its
own bottom edge sits ~15px below the actual glyphs even before <small>'s 0.3rem margin-top,
making the gap between filename and size look much bigger than that nominal margin. Reset once
here so both children (which don't set their own line-height) inherit something sane instead. */
line-height: 1.3;
}
.library-file-card strong {
@@ -10959,13 +11113,44 @@ 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
.library-link-text {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
min-width: 0;
height: auto;
min-height: 2.8rem;
margin: 0;
padding: 0.6rem 0.8rem;
border: 1px solid #eceef1;
border-radius: 0.4rem;
box-shadow: none;
color: var(--clr-primary-accent);
background: #fafbfc;
text-align: left;
text-transform: none;
line-height: 1.3;
}
.library-link-text span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Shared by VoiceCard/LinkText/MediaThumb/text-favorite's "removed" state (content-library-view.jsx)
-- same opacity + red label language as .library-file-card.removed/em above, but these 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. */
the source message is gone), so the removed state uses a simple centered flex row instead.
.library-text-content.removed covers a recalled/deleted *text* favorite or history-search hit --
plain text has no dedicated wrapper component of its own (FavoriteItem/History render it inline),
so the same modifier class is applied straight to the existing <p className="library-text-content">
rather than introducing a whole new component just for this one state. */
.library-voice-card.removed,
.library-link-text.removed,
.library-media-thumb.removed {
.library-media-thumb.removed,
.library-text-content.removed {
display: flex;
align-items: center;
justify-content: center;
@@ -10975,15 +11160,15 @@ div.toast.show {
.library-voice-card.removed em,
.library-link-text.removed em,
.library-media-thumb.removed em {
.library-media-thumb.removed em,
.library-text-content.removed em {
color: #d74a53;
font-size: 0.74rem;
font-style: normal;
}
.library-voice-card {
display: grid;
grid-template-columns: 2rem minmax(4rem, 12rem) auto;
display: flex;
align-items: center;
gap: 0.6rem;
width: min(100%, 20rem);
@@ -11402,9 +11587,12 @@ div.toast.show {
color: #9299a2;
}
/* DownloadPage's loading ("取消") and done ("用其他应用打开") states each render exactly one button
here -- never two side by side -- so the 2nd 1fr column sat permanently empty, pinning the
button to the left half with the right half dead space. */
.library-download-actions {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
gap: 0.8rem;
}
@@ -11491,6 +11679,14 @@ div.toast.show {
background: #dcecff;
}
/* Same class of bug as .library-file-card/.library-record-header above: this is a <button> and
the global `button{line-height:2.2rem}` reset inherits into the sender-name <strong> and date
<small>, inflating the gap around the search-result snippet in between. */
.library-message-results strong,
.library-message-results small {
line-height: 1.3;
}
.library-message-results small {
color: #969da5;
}
@@ -11501,8 +11697,44 @@ div.toast.show {
background: #fff;
}
.library-record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
min-width: 0;
height: auto;
margin: 0 0 0.4rem;
padding: 0;
border: 0;
box-shadow: none;
color: inherit;
background: transparent;
text-align: left;
text-transform: none;
/* Same fix as .library-file-card > div above: this is used as both a plain <div> and a <button>
(see the comment on the JSX call sites) -- only the <button> usage inherits the global
`button{line-height:2.2rem}` reset, so without resetting it here the two usages render this
row at two different heights/vertical rhythms depending on which element it happens to be. */
line-height: 1.3;
}
.library-record-header strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.library-record-header small {
flex: none;
color: #9299a2;
font-size: 0.74rem;
}
.library-record-files .library-file-card,
.library-record-links .library-link-card {
.library-record-links .library-link-text {
margin-bottom: 0.7rem;
}
@@ -11847,12 +12079,31 @@ div.toast.show {
object-fit: cover;
}
/* Name + trusted badge used to share one nowrap/ellipsis box -- when the name alone was long
enough to need truncating, the badge (last in source order) got truncated away with it,
entirely outside the visible area instead of just following a shortened name. Name now
truncates in its own flex child; the badge is a non-shrinking sibling so it always stays
on-screen right after whatever of the name fit. */
.call-peer-card h1 {
display: flex;
align-items: center;
margin: 1.3rem 0 0.45rem;
max-width: 100%;
color: #fff;
font-size: 1.55rem;
}
.call-peer-card h1 > span {
min-width: 0;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.call-peer-card h1 .trusted-badge {
flex: none;
}
.call-peer-card p {
margin: 0;
color: rgba(255,255,255,0.78);
@@ -12603,9 +12854,13 @@ div.toast.show {
width: unset;
}
#video-container.minimized {
/* :not(.audio-minimized) -- same specificity (1,1,0) as #video-container.audio-minimized (4149
above), and this rule comes later in the file so it was winning even for a voice call's pill,
stretching it into the same full-width box meant for the video floating window. */
#video-container.minimized:not(.audio-minimized) {
flex-direction: row;
left: 0;
right: auto;
top: var(--caption-panel-height);
width: 100%;
height: 11rem;
@@ -12811,10 +13066,14 @@ div.toast.show {
min-width: 0;
}
/* Same class of bug as .library-file-card above: .personal-identity is a <button> (头像+昵称+企业名
身份卡片, "我的" 页首屏第一眼看到的内容), and the global `button{line-height:2.2rem}` reset
inherits into these three stacked lines, inflating the gaps between nickname/企业名/ID. */
.personal-identity strong {
margin-bottom: 0.35rem;
overflow: hidden;
font-size: 1.25rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -12822,6 +13081,7 @@ div.toast.show {
.personal-identity small {
margin-top: 0.15rem;
color: #8a919a;
line-height: 1.3;
}
.personal-menu-row {
@@ -13992,13 +14252,20 @@ div.toast.show {
font-weight: 500;
}
.report-loading {
margin: 0;
padding: 1rem;
color: #6e7781;
font-size: 0.85rem;
}
.report-reason-list {
background: #fff;
}
.report-reason-list button {
display: grid;
grid-template-columns: 2.8rem minmax(0, 1fr) 1.5rem;
grid-template-columns: minmax(0, 1fr) 1.5rem;
align-items: center;
gap: 0.55rem;
width: 100%;
@@ -14014,17 +14281,17 @@ div.toast.show {
text-align: left;
}
.report-reason-list button > span:first-child {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.4rem;
color: #fff;
background: #4d70a8;
}
.report-reason-list button > span:nth-child(2),
/* ReportReasons (security-support-view.jsx) renders each row as a single `<span><strong>label
</strong></span>` followed by the trailing chevron -- there is no separate icon element, and
the reference design (docs/images/举报.png) confirms this list was never meant to carry a
per-row icon badge. This rule used to target that lone span as `span:first-child` with a fixed
2.5rem blue square + white text (sized/colored for an icon glyph, not a 4-character label) --
the 3-column grid above reserved a matching 2.8rem first column for it. With no icon in the
DOM, the real label text got force-fit into that narrow column: white-on-blue where it
overlapped the badge box, white-on-white (invisible) past its edge, so only a garbled sliver of
each reason's label was ever legible. Folded into the plain block/min-width:0 treatment below
(and the button grid down to 2 columns) so the label gets the full flexible column instead. */
.report-reason-list button > span:first-child,
.report-reason-list strong,
.report-reason-list small {
display: block;
@@ -14049,9 +14316,15 @@ div.toast.show {
padding-bottom: 1rem;
}
/* Same collision as .report-reason-list above: ReportDetails renders this section as just
`<span>{"举报原因"/reasonLabel}</span><button>修改</button>` -- two children, no leading icon --
but the grid reserved a 2.7rem first column for one anyway (matching the now-unused
`.material-icons` rule below, presumably meant for some future/removed icon). With no icon
present, the label span itself was squeezed into that 36px column, wrapping "举报原因"/the
reason text onto multiple lines instead of the single flexible column it needs. */
.report-selected-reason {
display: grid;
grid-template-columns: 2.7rem minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
padding: 0.9rem 1rem;
+1 -1
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="26" fill="#ffffff"/>
<path transform="rotate(-18 96 96)" fill="#1677ff"
d="M96,13.5 A82.5,82.5 0 0 1 178.5,96 L178.5,96 A82.5,82.5 0 0 1 96,178.5 L43.2,178.5 A29.7,29.7 0 0 1 13.5,148.8 L13.5,96 A82.5,82.5 0 0 1 96,13.5 Z"/>
<text x="96" y="98" text-anchor="middle" dominant-baseline="central"
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="77" fill="#ffffff">M</text>
<circle cx="156" cy="156" r="30" fill="#ffffff"/>
<circle cx="156" cy="156" r="24" fill="#3ba55c"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

+7 -1
View File
@@ -1 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192" height="192"><defs><filter id="f" color-interpolation-filters="sRGB"><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-1" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="fbSourceGraphic"/><feColorMatrix result="fbSourceGraphicAlpha" in="fbSourceGraphic" values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"/><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in2="fbSourceGraphic" in="flood" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-3" result="offset"/><feComposite in2="offset" in="fbSourceGraphic" result="composite2"/></filter><filter id="d" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#FFF" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="b" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="c" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><linearGradient id="a"><stop offset="0" stop-opacity=".251"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="102.814" y1="97.076" x2="219.019" y2="170.481" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.29306 0 0 1.00596 -43.959 .807)"/></defs><g fill-rule="evenodd"><path d="M14.102 64A88 87.925 0 008 95.924c-.012 44.455 18.485 70.138 1.863 79.822-12.733 7.419 43.153 8.258 70.137 8.248V64H14.102z" fill="#fc5d3e" filter="url(#b)"/><path d="M80 64v119.994c8.24-.003 14.055-.08 14.055-.08L96 183.85a88 87.925 0 0088-87.926A88 87.925 0 00177.99 64H80z" fill="#45ae49" filter="url(#c)"/><path d="M96 8A88 87.925 0 009.52 80h172.978A88 87.925 0 0096 8z" fill="#2d526b" filter="url(#d)"/></g><path d="M88.002 40.975l-8.6 112.808 39.791 26.94c34.169-9.369 59.381-38.298 63.971-73.4z" fill="url(#e)" fill-rule="evenodd" opacity=".5"/><text y="153.195" x="53.125" style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" font-weight="700" font-size="160" font-family="Arial" letter-spacing="0" word-spacing="0"><tspan style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" y="153.195" x="53.125" fill="#fff">t</tspan></text></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="26" fill="#ffffff"/>
<path transform="rotate(-18 96 96)" fill="#1677ff"
d="M96,13.5 A82.5,82.5 0 0 1 178.5,96 L178.5,96 A82.5,82.5 0 0 1 96,178.5 L43.2,178.5 A29.7,29.7 0 0 1 13.5,148.8 L13.5,96 A82.5,82.5 0 0 1 96,13.5 Z"/>
<text x="96" y="98" text-anchor="middle" dominant-baseline="central"
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="77" fill="#ffffff">M</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 510 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 500">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1a2c52"/>
<stop offset="1" stop-color="#0e1730"/>
</linearGradient>
</defs>
<rect width="750" height="500" fill="url(#bg)"/>
<g transform="translate(375 168)">
<rect x="-70" y="-70" width="140" height="140" rx="19" fill="#ffffff"/>
<path transform="rotate(-18)" fill="#1677ff"
d="M0,-60 A60,60 0 0 1 60,0 L60,0 A60,60 0 0 1 0,60 L-38.4,60 A21.6,21.6 0 0 1 -60,38.4 L-60,0 A60,60 0 0 1 0,-60 Z"/>
<text x="0" y="2" text-anchor="middle" dominant-baseline="central"
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="56" fill="#ffffff">M</text>
</g>
<text x="375" y="345" text-anchor="middle"
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="56" fill="#ffffff">MeetingIM</text>
<text x="375" y="392" text-anchor="middle"
font-family="Roboto, Arial, sans-serif" font-weight="400" font-size="24" fill="#aab4c8">即时通讯</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+9 -9
View File
@@ -2,27 +2,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tinode</title>
<title>MeetingIM</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web, version for developers" />
<meta name="description" content="Tinode instant messenger, development version" />
<meta name="application-url" content="https://web.tinode.co/">
<meta name="application-name" content="MeetingIM, version for developers" />
<meta name="description" content="MeetingIM instant messenger, development version" />
<meta name="application-url" content="https://web.118im.com/">
<!-- Browser styling for Chrome, FF, Opera -->
<meta name="theme-color" content="#3949AB" />
<!-- Ask Google not to offer page translation -->
<meta name="google" value="notranslate" />
<!-- Open Graph description -->
<meta name="og:url" content="https://web.tinode.co/">
<meta name="og:title" content="Tinode Web Development">
<meta name="og:url" content="https://web.118im.com/">
<meta name="og:title" content="MeetingIM Development">
<meta name="og:image" content="img/og-logo.jpeg">
<meta name="og:description" content="Tinode instant messenger, development version" />
<meta name="og:description" content="MeetingIM instant messenger, development version" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://web.tinode.co/" />
<link rel="canonical" href="https://web.118im.com/" />
<!-- Google's Roboto font -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<!-- Google's material design icons -->
@@ -43,7 +43,7 @@
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
MeetingIM does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.
</div>
</div></noscript>
+9 -9
View File
@@ -1,25 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<title>Tinode</title>
<title>MeetingIM</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web" />
<meta name="description" content="Tinode instant messenger in a browser" />
<meta name="application-url" content="https://web.tinode.co/">
<meta name="application-name" content="MeetingIM" />
<meta name="description" content="MeetingIM instant messenger in a browser" />
<meta name="application-url" content="https://web.118im.com/">
<meta name="theme-color" content="#3949AB" />
<meta name="google" value="notranslate" />
<meta name="format-detection" content="telephone=no" />
<meta name="og:url" content="https://web.tinode.co/">
<meta name="og:title" content="Tinode Web">
<meta name="og:url" content="https://web.118im.com/">
<meta name="og:title" content="MeetingIM">
<meta name="og:image" content="img/og-logo.jpeg">
<meta name="og:description" content="Tinode instant messenger in a browser" />
<meta name="og:description" content="MeetingIM instant messenger in a browser" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://web.tinode.co/" />
<link rel="canonical" href="https://web.118im.com/" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="css/base.min.css" />
@@ -32,7 +32,7 @@
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
MeetingIM does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.</div></div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
+4 -15
View File
@@ -1,7 +1,7 @@
{
"name": "Tinode",
"short_name": "Tinode",
"description": "Tinode Web App.\nSee more at https://tinode.co/",
"name": "MeetingIM",
"short_name": "MeetingIM",
"description": "MeetingIM instant messenger.",
"categories": ["chat", "communication", "productivity"],
"icons": [
{
@@ -25,18 +25,7 @@
"theme_color": "#3949AB",
"display": "standalone",
"gcm_sender_id": "103953800507",
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=co.tinode.tindroidx",
"id": "co.tinode.tindroidx"
},
{
"platform": "itunes",
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
}
],
"prefer_related_applications": false,
"permissions": {
"audio-capture": {
"description": "Required for recording voice messages"
+8 -9
View File
@@ -13,7 +13,7 @@ export const APP_NAME = BASE_APP_NAME + '/' + (PACKAGE_VERSION || '0.25');
export const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
// The array of possible hosts to connect to.
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
export const KNOWN_HOSTS = {hosted: 'web.118im.com', local: 'localhost:6060'};
// Default host name and port to connect to.
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
@@ -119,14 +119,13 @@ export const MIN_DURATION = 2_000;
// Maximum duration of an audio recording in milliseconds (10 min).
export const MAX_DURATION = 600_000;
// Link for "Contact Us".
export const LINK_CONTACT_US = 'mailto:support@tinode.co';
// Link to Privacy Policy.
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
// Link to Terms of Service.
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
// No real destination exists for these (this is a private multi-tenant product, not the open-source
// Tinode project) -- null rather than the upstream project's own contact/legal pages, which acc-support-view.jsx
// renders as inert text instead of a link (same treatment auth-onboarding-view.jsx already gives its
// own placeholder 用户协议/隐私政策 links).
export const LINK_CONTACT_US = null;
export const LINK_PRIVACY_POLICY = null;
export const LINK_TERMS_OF_SERVICE = null;
// When user starts a P2P topic, automatic subscription does not
// happen until the first message. Setting this option to true enables
+9 -1
View File
@@ -4,9 +4,17 @@ import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
export function detectServerAddress() {
let host = DEFAULT_HOST;
if (typeof window.location == 'object') {
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') {
if (window.location.protocol == 'file:') {
// No real page origin to derive a host/port from at all (e.g. a file:// build opened
// directly) -- this is the one case KNOWN_HOSTS.local's hardcoded port is actually needed.
host = KNOWN_HOSTS.local;
} else if (window.location.hostname) {
// Was `hostname == 'localhost'` on its own branch returning the hardcoded KNOWN_HOSTS.local
// ('localhost:6060') unconditionally -- any local/self-hosted deployment or dev proxy not on
// port 6060 (this project's own dev-proxy runs on 8093) got a wrong host and every "no
// cached tenant/host info yet" connection attempt (e.g. right after clearing storage) failed
// outright before a later reconnect could recover with the right address. Same-origin
// same-port assumption already used for every non-localhost host applies here too.
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
}
}
+14 -7
View File
@@ -76,10 +76,16 @@ const REASON_MESSAGES = {
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
// 独立密码校验的两个 reason(对接文档 809 行有写),青少年模式关闭/开启走的都是这条接口。
// 原来没收录,报错时直接透传后端英文原文——PIN 输错显示 "code invalid",连续错误锁定后
// 显示 "too many attempts",且不告诉用户要等多久能重试。
CODE_INVALID: '独立密码不正确',
// CODE_INVALID is shared by two unrelated verify endpoints -- independent-password PIN check
// (青少年模式关闭/开启走的这条接口,对接文档 809 行) *and* the plain SMS verification-code
// verify endpoints (/auth/verification-codes/{id}/verify, /security/challenges/{id}/verify --
// confirmed live: entering a wrong SMS code on the "忘记独立密码?" identity-check screen also
// gets back reason=CODE_INVALID). This table only keys off the reason string with no per-call-site
// override, so a message written for just one of the two ("独立密码不正确") showed up verbatim on
// the *other* endpoint's screen, where there's no "独立密码" concept in view at all -- confusing,
// wrong-sounding copy for a real, correctly-rejected wrong code. Kept generic enough to be accurate
// for both rather than wrong in either.
CODE_INVALID: '验证码或独立密码不正确',
TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds
? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试`
: '连续输错次数过多,请稍后重试'
@@ -268,7 +274,8 @@ const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除'
'gone': '内容不存在或已被删除',
'not found': '内容不存在或已被删除'
};
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
@@ -760,8 +767,8 @@ export const getOrganizationDepartmentMembers = (departmentId, cursor = '') =>
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
// list every time).
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = ''} = {}) =>
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor}));
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = '', limit = ''} = {}) =>
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor, limit}));
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
imGet('/organization/search' + qs({q: query, types, cursor}));
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
+16
View File
@@ -40,6 +40,22 @@ export default class HashNavigation {
window.location.hash = url;
}
// For switching between sub-tabs of the *same* page (e.g. content-library-view.jsx's favorites/
// history type tabs), not for navigating to a different page. `navigateTo`/`location.hash =`
// always pushes a new history entry, so clicking through N tabs pushes N entries -- the system
// back button (and this class's own goBack()) then has to step back through every tab the user
// ever visited before it reaches whatever page they were actually on before this one. `replaceState`
// updates the hash (still correct for a refresh or a copied link) without adding a history entry,
// so it doesn't inflate navigationCount either -- a tab switch was never a real in-app navigation
// to begin with. replaceState doesn't fire `hashchange` on its own, so dispatch one manually for
// the existing `window.addEventListener('hashchange', ...)` listener (tinode-web.jsx's
// handleHashRoute) to pick up; that handler re-reads window.location.hash itself rather than the
// event, so a synthetic event with no detail is indistinguishable from a real one to it.
static navigateReplace(url) {
window.history.replaceState(null, '', '#' + url);
window.dispatchEvent(new Event('hashchange'));
}
// Real "back": retraces the hash the user was actually on before, via native browser history,
// rather than a page's own guess at where it must have been reached from. Returns whether it
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
+2 -2
View File
@@ -1,7 +1,7 @@
// Odds and ends
import { Tinode } from 'tinode-sdk';
import { WALLPAPER_DEFAULTS } from '../config.js';
import { WALLPAPER_DEFAULTS, BASE_APP_NAME } from '../config.js';
// The backend's REST group layer writes a group's display name into its native Tinode `public`
// blob under a plain `name` key instead of the protocol-standard `fn` the SDK (and every other
@@ -36,7 +36,7 @@ export function updateFavicon(count) {
}
head.appendChild(newIcon);
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
document.title = (count > 0 ? '('+count+') ' : '') + BASE_APP_NAME;
}
// Create theCard which represents user's or topic's "public" info.
+18 -18
View File
@@ -2,27 +2,31 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Tinode } from 'tinode-sdk';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
// Renders as a real link when a destination is configured, or as inert (non-navigating) text
// otherwise -- same treatment auth-onboarding-view.jsx already gives its own placeholder
// 用户协议/隐私政策 links when there's no real page to send the user to.
function FlatLink({href, icon, children}) {
return href ?
<a href={href} className="flat-button" target="_blank"><i className="material-icons">{icon}</i> &nbsp;{children}</a> :
<span className="flat-button"><i className="material-icons">{icon}</i> &nbsp;{children}</span>;
}
export default class AccSupportView extends React.PureComponent {
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
<i className="material-icons">email</i> &nbsp;<FormattedMessage id="link_contact_us"
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</a>
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
<i className="material-icons">description</i> &nbsp;<FormattedMessage id="link_terms_of_service"
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</a>
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
<i className="material-icons">policy</i> &nbsp;<FormattedMessage id="link_privacy_policy"
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</a>
<FlatLink href={LINK_CONTACT_US} icon="email">
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</FlatLink>
<FlatLink href={LINK_TERMS_OF_SERVICE} icon="description">
<FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</FlatLink>
<FlatLink href={LINK_PRIVACY_POLICY} icon="policy">
<FormattedMessage id="link_privacy_policy" defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</FlatLink>
</div>
<div className="hr" />
<div className="panel-form-column">
@@ -31,10 +35,6 @@ export default class AccSupportView extends React.PureComponent {
description="Label for a client version" /></label>
{APP_NAME}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
{Tinode.getLibrary()}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
description="Label for a server version" /></label>
+51 -16
View File
@@ -1,6 +1,7 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
@@ -24,8 +25,8 @@ function Icon({children, className = ''}) {
function AppMark({tenantName}) {
return <div className="auth-brand">
<div className="auth-logo"><span>J</span></div>
<h1>{tenantName || '即时通讯'}</h1>
<div className="auth-logo"><span>{BASE_APP_NAME[0]}</span></div>
<h1>{tenantName || BASE_APP_NAME}</h1>
</div>;
}
@@ -59,7 +60,7 @@ function PasswordField(props) {
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
}
function Agreement({checked, onChange, prefix = '同意捷信密使'}) {
function Agreement({checked, onChange, prefix = `同意${BASE_APP_NAME}`}) {
return <label className="auth-agreement">
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
<span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
@@ -232,9 +233,14 @@ function EnterpriseRegister() {
return <div className="auth-page">
<Header title="企业注册" />
<main className="auth-content auth-content-narrow">
{/* .active marks a step (and the connector after it) as reached, not "this is the current
step" -- steps stay lit once passed. There's no separate server-tracked "确定信息" state
(submitting the form *is* confirming it), so step 2 only ever gets skipped straight from
1 to 3; without submitted also keeping step 1 lit, submitting made it look like step 1 had
been undone instead of completed. */}
<ol className="auth-steps">
<li className={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li>
<li><span>2</span><b>确定信息</b></li>
<li className="active"><span>1</span><b>填写企业信息</b></li>
<li className={submitted ? 'active' : ''}><span>2</span><b>确定信息</b></li>
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
</ol>
<form className="auth-form" onSubmit={submit}>
@@ -377,7 +383,7 @@ function VerificationButton({phone, scene = 'register', onChallenge, onError}) {
// no phone-vs-username mode toggle. "Phone takes precedence" (per the spec text) is validation-only:
// if a phone is filled it must be verified; the invite code is only required for a username-only
// signup (matching the mock's original submit-time validation, now with real backend calls behind it).
function Register({errorText, errorLevel, onImRegisterRequest}) {
function Register({errorText, errorLevel, persist, onImRegisterRequest, onPersistenceChange}) {
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
const [challengeId, setChallengeId] = React.useState('');
const [agreed, setAgreed] = React.useState(false);
@@ -386,6 +392,19 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
const change = key => e => setForm({...form, [key]: e.target.value});
const usingPhone = !!form.phone;
// Same silent "stay logged in" opt-in Login has (see its own copy of this effect for why there's
// no visible checkbox) -- missing here meant every account created by registering (as opposed to
// logging in) never had keep-logged-in/auth-token set, so handleLoginSuccessful() never persisted
// the session: the very next refresh silently lost myUserId with no redirect to the login page
// and no error, just a permanently empty messages-home shell. Every fresh registration hit this on
// its first reload.
React.useEffect(() => {
if (!persist) {
onPersistenceChange(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// onImRegisterRequest (tinode-web.jsx's handleImRegisterRequest -> a WS connect+createAccountBasic
// chain) isn't awaited and doesn't return a promise -- on the username/invite-code path (no
// verifyRegisterCode await ahead of it) `submit` returns and its `finally` clears `submitting`
@@ -451,7 +470,7 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
return <div className="auth-landing auth-register-page">
<form className="auth-landing-form auth-register-form" onSubmit={submit}>
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} />
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || BASE_APP_NAME} />
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
@@ -462,14 +481,17 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
onError={msg => setErrors(e => ({...e, code: msg}))} />} />
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
{/* 邀请码大小写敏感(server/store/... 的唯一索引是 ascii_bin 排序),不能像信用代码那样
.toUpperCase() 统一大小写;autoCapitalize="off" 只是防止移动端输入法把首字母偷偷转大写,
没有它,一个真实有效、首字母是小写的邀请码会被静默改错且用户毫无察觉。 */}
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
placeholder="请输入邀请码(非必填)" error={errors.invite} />
placeholder="请输入邀请码(非必填)" error={errors.invite} autoCapitalize="off" autoCorrect="off" />
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'}
</button>
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
<p className="auth-switch">已有{BASE_APP_NAME}账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
</form>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
<Agreement checked={agreed} onChange={setAgreed} prefix={`注册即认同${BASE_APP_NAME}`} />
</div>;
}
@@ -666,10 +688,20 @@ function Realname({authenticated = false}) {
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
<main className="auth-realname-content">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
{/* docs/images/实名认证-已实名.png 与 docs/images/实名认证.png 的标题文案完全一致,都是
"请您使用有效身份证信息认证",已实名状态并没有换一句"认证信息"——原来这里按 status
分叉出两句不同文案,是从最初的 mock 阶段就带着、从未跟真实原型图核对过的臆造文案,
一旦账号真的走到 verified 状态就会显示一句参考图里根本不存在的标题。 */}
<h2>请您使用有效身份证信息认证</h2>
<form onSubmit={submit}>
<label><span>姓名</span><input value={editable ? name : maskedName} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={editable ? idNumber : maskedIdNumber} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
{/* submitRealname's success response never actually carries masked_name/masked_id_number
(only {status, submission_id}) -- falling back to what was just typed (still sitting in
`name`/`idNumber` local state) means a fresh submission at least shows the user what
they submitted, instead of switching straight from their typed input to two blank
fields the instant `editable` flips false. Only degrades to genuinely blank on a fresh
page load with no draft in this session, which is the pre-existing, unavoidable case. */}
<label><span>姓名</span><input value={editable ? name : (maskedName || name)} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={editable ? idNumber : (maskedIdNumber || idNumber)} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" type="submit" disabled={!editable || busy}>
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
@@ -757,8 +789,10 @@ function IndependentPassword({authenticated, hashParams}) {
<Header title="" back={authenticated ? 'security' : 'login'} />
<main className="auth-pin-content">
<h1>设置独立密码</h1>
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
<div className="auth-pin-wrap">
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
</div>
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" disabled={pin.length !== 4 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
</main>
@@ -778,7 +812,8 @@ export default function AuthOnboardingView({
errorText={errorText} errorLevel={errorLevel} onLoginRequest={onLoginRequest} onPersistenceChange={onPersistenceChange} onError={onError} />;
case 'enterprise-register': return <EnterpriseRegister />;
case 'enterprise-feedback': return <EnterpriseFeedback />;
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} persist={persist}
onImRegisterRequest={onImRegisterRequest} onPersistenceChange={onPersistenceChange} />;
case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} hashParams={hashParams} />;
case 'realname': return <Realname authenticated={authenticated} />;
+13 -8
View File
@@ -106,6 +106,7 @@ function AddressBook({myUserId, onTopicSelected}) {
const [friends, setFriends] = React.useState(() => (friendsCacheKey && readCachedData(friendsCacheKey)) || []);
const [pendingCount, setPendingCount] = React.useState(0);
const [loading, setLoading] = React.useState(() => !(friendsCacheKey && readCachedData(friendsCacheKey)));
const [loadError, setLoadError] = React.useState(false);
// Same bootstrap the message center already fetches (and caches under the same key) -- only need
// it here for the file assistant's real topic, so a plain read of the shared cache is enough.
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
@@ -117,11 +118,11 @@ function AddressBook({myUserId, onTopicSelected}) {
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
setFriends(list);
}).catch(() => {}).finally(() => setLoading(false));
}).catch(() => setLoadError(true)).finally(() => setLoading(false));
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 visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.toLowerCase().includes(query.toLowerCase()));
const groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name);
return {...result, [key]: (result[key] || []).concat(item)};
@@ -148,7 +149,7 @@ function AddressBook({myUserId, onTopicSelected}) {
<main className="people-scroll">
<section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => openEntry(item)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>
<h3 className="people-section-title">好友通讯录</h3>
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">暂无好友</p> : null}
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">{loadError ? '通讯录加载失败' : '暂无好友'}</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); }}>
{/* 副行只在确实是另一个名字时才显示。后端在有备注时会把 display_name 也设成备注
@@ -174,9 +175,10 @@ function requestStatusLabel(item) {
function FriendRequests({onToast}) {
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
fetchAllPages(cursor => getFriendRequests('all', 'all', cursor))
.then(setItems).catch(() => {}).finally(() => setLoading(false));
.then(setItems).catch(() => setLoadError(true)).finally(() => setLoading(false));
}, []);
const handle = (item, accept) => {
const nextStatus = accept ? 'accepted' : 'rejected';
@@ -188,7 +190,7 @@ function FriendRequests({onToast}) {
});
};
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">
{loading ? <p className="people-empty-hint">加载中…</p> : items.length === 0 ? <p className="people-empty-hint">暂无新朋友申请</p> : null}
{loading ? <p className="people-empty-hint">加载中…</p> : items.length === 0 ? <p className="people-empty-hint">{loadError ? '加载失败,请重试' : '暂无新朋友申请'}</p> : null}
{items.map(item => <article key={item.request_id}>
<Avatar person={item.peer} />
<div><strong>{(item.peer && item.peer.display_name) || item.applicant_id}</strong><p>{item.message || '请求添加您为好友'}</p><small>{new Date(item.created_at).toLocaleString()}</small></div>
@@ -548,7 +550,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
}} /></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> :
// Backend column is VARCHAR(64) and actually enforces it (65 chars -> 400) -- confirmed
// live, this used to cap input at less than a third of what the server accepts.
<div className="people-remark-edit"><input value={remarkDraft} maxLength="64" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
<button type="button" onClick={() => { setRemarkDraft(remark); setEditingRemark(true); }}><span>备注</span>{remark ? <small>{remark}</small> : null}<Icon>chevron_right</Icon></button>}
<div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
if (value) { navigate('block-confirm', uid); return; }
@@ -572,8 +576,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
function BlockList({onToast}) {
const [people, setPeople] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => {}).finally(() => setLoading(false));
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => setLoadError(true)).finally(() => setLoading(false));
}, []);
const remove = userId => {
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
@@ -584,7 +589,7 @@ function BlockList({onToast}) {
return <div className="people-page"><Header title="黑名单" back="android-settings" /><main className="people-block-list">
{loading ? <p className="people-empty-hint">加载中…</p> : people.length ?
people.map(item => <article key={item.user_id}><Avatar person={item.user} /><span><strong>{(item.user && item.user.display_name) || item.user_id}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => remove(item.user_id)}>移出</button></article>) :
<div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}
<div className="people-empty"><Icon>person_off</Icon><strong>{loadError ? '黑名单加载失败' : '黑名单为空'}</strong></div>}
</main></div>;
}
+129 -23
View File
@@ -8,21 +8,35 @@ 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, resolveFileUrl, getGroup, getUserProfile
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile,
getConversationContext
} from '../lib/im-api.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
// display name the same way every other list in this app does, cached since many favorites usually
// share a handful of source conversations.
// favoritePayload's source.topic is the raw conversation topic id -- for a group that's grpXXX, but
// for a P2P source it's the *composite* p2p topic id (e.g. "p2pU_as9N_wgZphV5XTJmzy3A"), not the bare
// "usrXXX" uid the comment here used to assume. Passing that composite id straight to
// /users/{id}/profile 404s every time (confirmed live: MALFORMED_REQUEST/"user not found"), so every
// P2P-sourced favorite silently fell back to displaying the raw p2pXXX topic string instead of the
// friend's name -- the .catch(()=>{}) below swallowed the failure with no visible error. There is no
// way to derive the peer's uid from that composite string on the client: im_be's ParseP2P (server/
// im_http.go's resolveConversationContextTopic) decodes it, but that decoding never runs client-side,
// and the already-subscribed chatList doesn't help either since Tinode keys a P2P contact by the
// peer's bare uid there, not by this composite id, so no local list lookup can bridge the two.
// GET /conversations/{topic}/context (im-api.js's getConversationContext, already used elsewhere for
// relationship state) exists precisely to resolve a p2p topic server-side and conveniently returns
// the resolved peer's own `public` data in the same response -- reuse that instead of guessing at a
// uid shape that doesn't apply here. Cached since many favorites usually share a handful of source
// conversations.
const sourceNameCache = new Map();
function resolveSourceName(topic, onDone) {
if (!topic) return;
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; }
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : getUserProfile(topic).then(p => p.display_name);
call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {});
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) :
getConversationContext(topic).then(ctx => ctx.peer && topicDisplayName(ctx.peer_user_id, ctx.peer.public, ctx.peer_user_id));
call.then(name => { if (name) { sourceNameCache.set(topic, name); onDone(name); } }).catch(() => {});
}
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
@@ -37,6 +51,13 @@ const navigate = (view, params) => {
HashNavigation.navigateTo(hash);
};
// Same as navigate() above but for switching this page's own type tabs (Favorites' 全部/图片/.../链接,
// History's 聊天记录/图片/视频/文件/链接) -- these change which content shows on the *same* page, not
// which page the user is on, so they shouldn't push a browser-history entry the way a real navigate()
// does. Previously every tab click called navigate(), so N tab switches pushed N history entries and
// the system back button just cycled through past tabs instead of leaving the page.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
@@ -91,6 +112,14 @@ function resolveAttachmentUrl(att) {
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
}
// A video entity's .val/.ref (what resolveAttachmentUrl above resolves) is the video's own bytes --
// meant for a real <video> element, not decodable by a plain <img>. The poster frame lives in the
// separate .preview/.premime fields the backend already sends alongside it, same convention
// lib/formatters.js's quoteImageOrVideo()/inlineVideoAttr() rely on for the chat-bubble quote case.
function videoPosterUrl(att) {
if (!att || !att.preview) return null;
return 'data:' + (att.premime || 'image/jpeg') + ';base64,' + att.preview;
}
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
@@ -109,15 +138,27 @@ function plainTextOf(content, fallbackText) {
}
// file_type -> {icon, color} matching Ch.6's group-file convention (a colored square + letter/icon).
const FILE_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : '';
}
// Ch.6's getGroupFiles() returns a server-classified file_type field; favorites/history items have
// no equivalent (they're derived from a Drafty attachment, not a registered file record), so it's
// approximated here from mime/extension instead of left unclassified.
function fileTypeFromMime(mime, name) {
const prefix = (mime || '').split('/')[0];
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
const ext = extFromName(name).toLowerCase();
if (mime === 'application/pdf' || DOCUMENT_EXT.includes(ext)) return 'document';
return 'file';
}
function FileCard({name, size, mime, sender, removed, onOpen}) {
const ext = extFromName(name);
const [, color] = FILE_TYPE_META[fileTypeFromMime(mime, name)];
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
<span>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
<span style={{backgroundColor: color}}>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
<div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
</button>;
@@ -153,34 +194,70 @@ function LinkText({url, onOpen, removed}) {
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, removed}) {
// 需求7.4「支持长文本折叠、展开和复制」外加"搜索结果应对匹配关键词进行高亮显示"——原实现整段
// 需求在这里都没接:文本收藏是一段不限高度的纯 <p>,长文本不折叠也没有展开/收起,没有复制按钮,
// 收藏搜索的 query 也从没传到这一层,History 那边已经在用的 Highlight 组件在 Favorites 里从未
// 被引用过(实测:在"我的收藏-文本"搜索一个真实命中的关键词,结果列表 0 个 <mark>)。
// overflowing 用 scrollHeight/clientHeight 判断 line-clamp 是否真的裁到了内容(标准判定手法),
// 避免给短文本也显示一个没用的"展开"按钮;复制走跟 context-menu.jsx 的 copyMessageText 完全一样
// 的 navigator.clipboard.writeText,保持与聊天消息长按菜单里"复制"一致的行为。
function FavoriteText({text, query, onToast}) {
const [expanded, setExpanded] = React.useState(false);
const [overflowing, setOverflowing] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
const el = ref.current;
if (el) setOverflowing(el.scrollHeight - el.clientHeight > 1);
}, [text]);
const copy = () => {
navigator.clipboard.writeText(text || '').then(() => onToast('已复制')).catch(() => onToast('复制失败'));
};
return <div className="library-text-wrap">
<p ref={ref} className={'library-text-content' + (expanded ? '' : ' clamped')}><Highlight text={text} query={query} /></p>
<div className="library-text-actions">
{overflowing ? <button type="button" onClick={() => setExpanded(v => !v)}>{expanded ? '收起' : '展开'}</button> : null}
<button type="button" onClick={copy}><Icon>content_copy</Icon>复制</button>
</div>
</div>;
}
function MediaThumb({url, poster, video, duration, onOpen, removed}) {
if (removed) {
return <div className="library-media-thumb removed"><Icon>{video ? 'videocam_off' : 'broken_image'}</Icon><em>{video ? '视频已失效' : '图片已失效'}</em></div>;
}
// video's own bytes (url) can't be decoded by <img> -- only the poster frame can. Images have no
// separate poster; their own url doubles as the thumbnail.
const imgSrc = video ? poster : url;
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}
{imgSrc ? <img src={imgSrc} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
{/* duration follows the AU/VD entity storage convention of milliseconds (see sendVideoAttachment/
insertVideo), same conversion widgets/audio-player.jsx already does for its own duration prop. */}
{video ? <><Icon>play_circle</Icon>{duration > 0 ? <small>{secondsToTime(duration / 1000)}</small> : null}</> : null}
</button>;
}
function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
function FavoriteItem({item, onOpen, selecting, selected, onSelect, query, onToast}) {
const att = firstAttachment(item.snapshot && item.snapshot.content);
const source = item.source_display || (item.source && item.source.topic) || '';
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
{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}
{/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard),
voice/link/image/video 三类完全没处理——即便后端把"问题13"(availability 恒为 available)
修好,这三类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
"还在加载中"没法区分的占位。四个类型现在统一都传。后端 messageAvailability(im_http.go)
在源消息被删/被撤回时给 'removed'。 */}
text/voice/link/image/video 全部没处理——即便后端把"问题13"(availability 恒为 available)
修好,这五类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
"还在加载中"没法区分的占位(text 类型更糟:源消息撤回后 snapshot 里的旧文本会原样一直
显示下去,没有"加载中"和"已失效"的区别,看起来像是这条消息完全没被撤回)。现在五个类型
统一都传。后端 messageAvailability(im_http.go)在源消息被删/被撤回时给 'removed'。 */}
{item.type === 'text' ? (item.availability === 'removed' ?
<p className="library-text-content removed"><Icon>block</Icon><em>文本已失效</em></p> :
<FavoriteText text={plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)} query={query} onToast={onToast} />) : null}
{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}
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} poster={item.type === 'video' ? videoPosterUrl(att) : null}
video={item.type === 'video'} duration={att && att.duration} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div>
</article>;
@@ -208,8 +285,16 @@ function Favorites({view, tinode, onToast}) {
const [hasMore, setHasMore] = React.useState(false);
const [fileUrls, setFileUrls] = React.useState({});
const [sourceNames, setSourceNames] = React.useState({});
// An in-flight request isn't guaranteed to resolve before a newer one its own type/query change
// triggered -- without this, a slow full-list request landing after a fast, narrower search
// response overwrites the correct filtered results with the stale unfiltered ones (confirmed
// live: search box showing a query, list showing all items). Each load() call claims the counter
// for itself; a response only applies if nothing newer has been requested since.
const requestIdRef = React.useRef(0);
const load = React.useCallback((append) => {
const requestId = ++requestIdRef.current;
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
if (requestIdRef.current !== requestId) return;
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query) writeCachedData('favorites:' + type, list);
@@ -278,13 +363,13 @@ function Favorites({view, tinode, onToast}) {
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
<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>
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigateReplace(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
<main className="library-favorite-list">
{items === null ? <p className="library-empty-hint">加载中…</p> :
list.length ? <>{list.map(item => <FavoriteItem key={item.favorite_id} item={{...item, _thumbUrl: fileUrls[item.favorite_id],
source_display: sourceNames[item.source && item.source.topic]}}
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
onOpen={() => openItem(item)} />)}
onOpen={() => openItem(item)} query={query} onToast={onToast} />)}
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}</> :
<div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div>}
</main>
@@ -485,7 +570,7 @@ function Highlight({text, query}) {
function HistoryNav({active}) {
const tabs = [['history-search', '聊天记录'], ['history-images', '图片'], ['history-videos', '视频'], ['history-files', '文件'], ['history-links', '链接']];
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>;
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigateReplace(item[0])}>{item[1]}</button>)}</nav>;
}
const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
@@ -511,12 +596,18 @@ function History({view, hashParams, myUserId, onToast}) {
const [hasMore, setHasMore] = React.useState(false);
const [mediaUrls, setMediaUrls] = React.useState({});
const [senderNames, setSenderNames] = React.useState({});
// Same stale-response guard as Favorites' load() above and for the same reason -- a slower
// request for a since-abandoned topic/tab/query can still resolve after a newer one and
// overwrite its correct results.
const requestIdRef = React.useRef(0);
const load = React.useCallback((append) => {
if (!topic) return;
const requestId = ++requestIdRef.current;
const type = HISTORY_TYPE[view];
const call = view === 'history-search' ? searchTopicMessages(topic, {query, type, cursor: append ? cursor : ''}) :
getTopicMedia(topic, type, append ? cursor : '');
call.then(data => {
if (requestIdRef.current !== requestId) return;
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query && cacheKey) writeCachedData(cacheKey, list);
@@ -579,9 +670,18 @@ function History({view, hashParams, myUserId, onToast}) {
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
view === 'history-search' ? <section className="library-message-results">
{/* 同一处"问题13"修复暴露的缺口,History 自己的一份,只影响 type==='text':非 text 类型
始终走 TYPE_LABEL[item.type] 的固定方括号标签("[图片]"等),从不读 item.snippet,
所以 removed 与否对它们没有可观察差异,不需要改。text 类型是唯一真正显示 item.snippet
的分支——一个没有搜索关键词的浏览(query 为空,后端不加内容过滤,撤回/删除的消息也会
原样出现在结果里)如果命中一条已撤回的文本消息,item.snippet 现在会是后端
messageText() 对 nil content 兜底出来的字面量字符串 "null"(json.Marshal(nil)),而不
是空字符串——不判断 availability 的话,用户会看到一行显示"null"的消息,比"看起来没被
撤回"更令人困惑。 */}
{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 || ''} query={query} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
(item.availability === 'removed' ? '文本已失效' : <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> :
@@ -603,8 +703,14 @@ function History({view, hashParams, myUserId, onToast}) {
<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)} removed={item.availability === 'removed'} />)}</section>}
<section className="library-media-grid">{list.map(item => {
// mediaMessagePayload (getTopicMedia) is flat and has no top-level `duration`/poster fields --
// the real values live inside the reconstructed Drafty entity, same as history-files' FileCard
// already reconstructs `att` for name/size/mime just above.
const videoAtt = view === 'history-videos' ? attachmentFromFlat(item) : null;
return <MediaThumb key={item.seq} url={mediaUrls[item.seq]} poster={videoAtt ? videoPosterUrl(videoAtt) : null} video={view === 'history-videos'}
duration={videoAtt && videoAtt.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>;
+32 -16
View File
@@ -535,14 +535,15 @@ function GroupProfile({topic, myUserId, onToast}) {
<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>
</button> :
<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>}
{/* Swapping to a bare <div> when !canEdit used to drop every .group-profile row rule (all of
them are scoped to > button/> label) -- same fix as the avatar row just above: keep
<button>, gate it with disabled like that row already does. */}
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-nickname', topic)}>
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
</button>
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-description', topic)}>
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
</button>
<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>
@@ -637,7 +638,7 @@ const GOVERNANCE_FIELDS = [
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
];
function Governance({topic, tinode, onToast}) {
function Governance({topic, myRole, tinode, onToast}) {
// Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's
// `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks
// and returns, so this is one of the highest-traffic pages in the file.
@@ -659,6 +660,11 @@ function Governance({topic, tinode, onToast}) {
.catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
// Backend already refuses to actually destroy the group for a non-owner (the same native
// {del hard:true} call it silently downgrades into "remove me from this group" instead) --
// but that response is indistinguishable from a real success, so without this gate a member
// who somehow lands on this page got told "群聊已解散" when they'd really just left it.
if (myRole !== 'owner') return;
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return;
@@ -683,7 +689,7 @@ function Governance({topic, tinode, onToast}) {
<span><strong>{label}</strong><small>{desc}</small></span>
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
</div>)}</section>
<button type="button" className="group-danger" disabled={dissolving} onClick={dissolve}>解散群组</button>
<button type="button" className="group-danger" disabled={dissolving || myRole !== 'owner'} onClick={dissolve}>解散群组</button>
</main>
</div>;
}
@@ -719,8 +725,18 @@ function AdminList({topic, onToast}) {
}, [topic]);
React.useEffect(() => { load(); }, [load]);
const remove = userId => {
deleteGroupAdmin(topic, userId).then(() => { setToast('管理员已移除'); load(); })
.catch(err => onToast(err.message || '操作失败'));
// deleteGroupAdmin succeeding doesn't mean a follow-up load() will show it -- getGroupMembers
// is backed by a server-side cache that this exact query shape can keep serving stale for a
// while (confirmed live: an unfiltered re-query right after removal still showed the removed
// admin, while a query with different params the session hadn't used yet returned correctly).
// Patch the already-known-correct result into local state and the shared cache directly
// instead of trusting a re-fetch to reflect what was just confirmed to have happened.
deleteGroupAdmin(topic, userId).then(() => {
setToast('管理员已移除');
setAdmins(prev => prev ? prev.filter(a => a.user_id !== userId) : prev);
const cached = readCachedData('group-members:' + topic);
if (cached) writeCachedData('group-members:' + topic, cached.map(m => m.user_id === userId ? {...m, role: 'member'} : m));
}).catch(err => onToast(err.message || '操作失败'));
};
return <div className="group-page">
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
@@ -752,7 +768,7 @@ function GroupMembers({topic, onToast}) {
setMembers(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return <div className="group-page">
@@ -787,7 +803,7 @@ function MemberPicker({type, topic, onToast}) {
.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));
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => {
@@ -901,7 +917,7 @@ function OwnerSelect({topic, onToast}) {
.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 visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
@@ -1148,7 +1164,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
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 (view === 'group-governance') content = <Governance topic={topic} myRole={myRole} 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} />;
else if (view === 'group-admin-add') content = <MemberPicker type="admin-add" topic={topic} onToast={setToast} />;
else if (view === 'group-mute-add') content = <MemberPicker type="mute-add" topic={topic} onToast={setToast} />;
+12
View File
@@ -10,6 +10,7 @@ import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import HashNavigation from '../lib/navigation.js';
import MenuCancel from '../widgets/menu-cancel.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import ShowQRCode from '../widgets/show-qrcode.jsx';
@@ -445,7 +446,18 @@ class InfoView extends React.Component {
this.setState({selectedContact: uid});
}
// Every drill-in click inside InfoView (open members/permissions/qrcode/security/...) goes through
// props.onNavigate -> tinode-web.jsx's infoNavigator -> a real navigateTo() push, so this "go back
// one level" (or "close InfoView entirely" at the root) should be the matching pop -- goBack() first,
// same fix/pattern as tinode-web.jsx's handleHideMessagesView and content-library-view.jsx's Header.
// Before this fix every level of InfoView's own drill-down (info -> members -> a member's profile ->
// permissions -> ...) pushed going in AND pushed again coming back out via this function, so the
// system back button had to be pressed once per level ever visited, and each press could land back
// inside a sub-panel the user had already explicitly backed out of.
handleBackNavigate() {
if (HashNavigation.goBack()) {
return;
}
const args = (this.props.panel || 'info').split('/');
if (args[0] == 'info') {
this.props.onNavigate(null);
+47 -6
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.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';
@@ -19,6 +20,15 @@ const navigate = (view, topic) => {
HashNavigation.navigateTo(hash);
};
// BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a
// "different page" in the back-stack sense -- same bug/fix as content-library-view.jsx's
// navigateReplace (see its comment): navigate() here pushed a history entry on every tab click, so
// bouncing between 消息 and 工作台 a few times (a completely normal usage pattern for the app's only
// two bottom tabs) made the back button just cycle through past tab switches instead of leaving the
// app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past
// tab switch to back out.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
// 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
// a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently
@@ -182,7 +192,7 @@ function normalizeConversations(chatList, myUserId) {
function BottomNavigation() {
return <nav className="messages-bottom-nav" aria-label="主导航">
<button type="button" onClick={() => navigate('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
<button type="button" onClick={() => navigateReplace('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
<button type="button" className="active"><Icon>person</Icon><span>消息</span></button>
</nav>;
}
@@ -235,7 +245,15 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
// Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a
// beat) every time this component remounted, e.g. after a trip to another sidepanel and back.
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
// sidepanel-view.jsx 会在 sidePanelSelected 已经是 'messages-home'(例如收藏夹里的旧链接、浏览器
// 从上次关闭前恢复的标签页)但 myUserId 还没就绪时,照样把这个视图渲染出来(它自己的
// `fullPage = !myUserId || view !== 'messages-home'` 分支就是为这种情况准备的)——如果这时候
// token 续登录失败或者还没跑完,原来这里只看 `!!myUserId && connected===false`,myUserId 本身是
// null 时这个条件恒为 false,于是不会进入"加载中"分支,会话列表和 bootstrap 又因为
// useCachedData 的 key 是 null 而永远不发请求,最终呈现的是一句容易误导人的"暂无会话/未找到
// 相关会话"——用户会以为账号是空的,而不是"还没登录成功"。myUserId 本身为空也按"未连接"处理,
// 至少诚实地展示"加载中"而不是一个看起来正常但其实是半成品的空列表。
const offline = view === 'messages-offline' || !myUserId || connected === false;
// "置顶" 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
@@ -325,7 +343,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
{item.unread ? <b>{item.unread > 99 ? '99+' : item.unread}</b> : item.muted ? <Icon>notifications_off</Icon> : null}
</span>
</button>)}
{filtered.length === 0 ? <div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> : null}
{/* "未找到相关会话"(搜索图标)只在用户真的输入过搜索词却匹配不到时才准确——`conversations`
本身为空(例如尚未完成登录/数据尚未拉取到、或租户把系统消息与文件助手都关闭且用户还没有
任何会话)跟"搜索没匹配到"是两种不同情况,原来两者共用同一句"未找到相关会话"+放大镜
图标,会把"这个账号目前就是没有任何会话"说成"你的搜索没找到东西",用户没搜索过却看到
搜索无结果的图标和文案。 */}
{filtered.length === 0 ? (query.trim() ?
<div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> :
<div className="messages-empty"><Icon>forum</Icon><p>暂无会话</p></div>) : null}
</section>
</main>
<BottomNavigation />
@@ -385,6 +410,8 @@ function MessageActions() {
// 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
@@ -415,16 +442,29 @@ function systemNotificationText(item) {
function SystemMessages({authenticated}) {
const [items, setItems] = React.useState([]);
const [cursor, setCursor] = React.useState('');
const [loadingMore, setLoadingMore] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
React.useEffect(() => {
getSystemNotifications().then(data => setItems(data.items || []))
getSystemNotifications().then(data => { setItems(data.items || []); setCursor(data.next_cursor || ''); })
.catch(err => setError(err.message || '加载失败'))
.finally(() => setLoading(false));
}, []);
// GET /system-notifications does return next_cursor whenever there's more (real accounts with
// 50 unread saw it truncate silently at 20, with no way to reach or ever mark the rest read).
const loadMore = () => {
if (loadingMore || !cursor) return;
setLoadingMore(true);
getSystemNotifications(cursor).then(data => {
setItems(list => list.concat(data.items || []));
setCursor(data.next_cursor || '');
}).catch(() => {}).finally(() => setLoadingMore(false));
};
const markRead = item => {
if (item.read_at) return;
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));
@@ -457,14 +497,15 @@ function SystemMessages({authenticated}) {
猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */}
{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') ?
item.event_type === 'friend.request.created' ?
<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>
<footer>保持警惕<br />{BASE_APP_NAME}安全团队</footer>
</div>
</article>)}
{cursor ? <button type="button" className="system-notification-load-more" disabled={loadingMore} onClick={loadMore}>{loadingMore ? '加载中…' : '加载更多'}</button> : null}
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
+57 -5
View File
@@ -3,7 +3,7 @@
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
import { Drafty, Tinode, TheCard, LOCAL_SEQID } from 'tinode-sdk';
const CallPanel = React.lazy(_ => import('../widgets/call-panel.jsx'));
import ChatMessage from '../widgets/chat-message.jsx';
@@ -288,6 +288,45 @@ class MessagesView extends React.Component {
this.componentSetup({}, {});
// Ch.7: jump to a message found via chat-history search/media grid (content-library-view.jsx's
// History) -- the componentDidUpdate check a few lines below only fires on a *change* to
// jumpToSeq while this component stays mounted, but tinode-web.jsx never renders MessagesView
// and a Ch.7 side panel at the same time (`!chapterSevenActive` gates it) -- every jump from
// History's search results/image grid/video grid therefore unmounts the Ch.7 view and mounts a
// *brand new* MessagesView instance with jumpToSeq already set on its very first render, which
// componentDidUpdate never sees (it doesn't run for the initial mount). Confirmed live: the
// seq stayed in the URL indefinitely (jumpToMessage's own `removeUrlParam` call never fired)
// and the conversation opened at the bottom instead of scrolling to the found message --
// 100% reproducible for every Ch.7 "定位到消息来源" entry point, not an intermittent timing issue.
if (this.props.jumpToSeq) {
const seq = parseInt(this.props.jumpToSeq, 10);
const topicObj = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
const attemptJump = () => this.jumpToMessage(seq);
// A first-ever mount also means `componentSetup()` just above only *started* subscribing
// this topic (subscribe() -> topic.subscribe() is async, fire-and-forget from here) --
// jumpToMessage's own topic.getMeta() call requires an already-established subscription,
// and firing immediately races ahead of it: confirmed live, this raced getMeta() call came
// back as a real `{"code":403,"text":"permission denied"}` control message (not a local
// guard, an actual rejected server round trip) while the real subscribe's own request was
// still in flight one id apart. Poll the already-exposed isSubscribed() flag rather than
// guessing a fixed delay or reworking subscribe()'s fire-and-forget call sites to expose
// their promise; capped at 5s so a genuinely stuck subscribe still falls through to
// jumpToMessage's own existing error handling instead of waiting forever.
if (topicObj && !topicObj.isSubscribed()) {
let tries = 0;
const waitForSubscription = () => {
if (topicObj.isSubscribed() || ++tries > 25) {
attemptJump();
} else {
window.setTimeout(waitForSubscription, 200);
}
};
waitForSubscription();
} else {
attemptJump();
}
}
getBootstrap().then(data => {
this.setState({messagePolicy: data.message_policy || null});
}).catch(_ => {});
@@ -1088,10 +1127,22 @@ class MessagesView extends React.Component {
// allowAdmin=true, so the backend has always accepted this; the reference screenshot
// (消息页面—长按弹出.png) lists "群撤回" as its own, always-shown menu item alongside "撤回" --
// this was previously never offered at all since the only condition checked was authorship.
//
// `params.seq > 0` alone is not enough to mean "a real, server-persisted message": Tinode
// SDK gives every message that hasn't been confirmed by the server yet (still sending, or
// failed outright) a local placeholder seq of LOCAL_SEQID (0xFFFFFFF = 268435455) -- a large
// *positive* number, so it passed this check. Round 11 live testing reproduced this: sending
// a message into a since-blocked P2P topic left a bubble stuck in "发送失败" with seq
// 268435455, and this menu still offered "撤回" for it -- clicking it fired
// POST /topics/.../messages/268435455/recall, which the server correctly rejects (no such
// message), surfacing a confusing "请求内容有误,请稍后重试" toast for an action that should
// never have been offered. `seq < LOCAL_SEQID` excludes exactly that placeholder range,
// matching the same bound the SDK itself uses internally to distinguish a confirmed seq from
// a queued one.
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && recallWindow > 0 &&
params.timestamp && params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && params.seq < LOCAL_SEQID &&
recallWindow > 0 && params.timestamp && params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
menuItems.push('menu_item_recall');
}
// Self -> (1) always hard-delete with [Delete] title.
@@ -2228,7 +2279,9 @@ class MessagesView extends React.Component {
const messageNodes = [];
let previousFrom = null;
let prevDate = null;
let chatBoxClass = null;
// Set upfront (not just inside the loop below) so an empty (zero-message) conversation still
// gets the base .chat-box class instead of leaving <ul id="scroller"> with no class at all.
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
topic.messages((msg, prev, next, i) => {
let nextFrom = next ? (next.from || 'chan') : null;
@@ -2254,7 +2307,6 @@ class MessagesView extends React.Component {
userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo);
}
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
// Ref for this chat message.
const ref = this.getOrCreateMessageRef(msg.seq);
+5 -1
View File
@@ -56,7 +56,11 @@ class NewTopicView extends React.Component {
handleTabClick(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
// Same-page tab switch (find user / new group / find by id), not a navigation to a different
// page -- navigateTo() pushed a history entry per click purely to keep the `tab` url param in
// sync, so repeatedly switching tabs made the back button cycle through past tabs instead of
// leaving this page. navigateReplace() keeps the param in sync without that side effect.
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
+53 -32
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, LINK_CONTACT_US } from '../config.js';
import { BASE_APP_NAME } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
@@ -22,7 +22,7 @@ const navigate = (view, params) => {
export const PERSONAL_SETTINGS_VIEWS = [
'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',
'change-password',
'app-version', 'privacy-settings', 'friend-discovery-settings',
'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
'notification-permission', 'permission-settings-reminder', 'camera-permission-info',
@@ -282,7 +282,7 @@ function PersonalQr({onToast}) {
// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。
function SettingsList({onToast, onLogout}) {
function SettingsList({onToast, onLogout, tinode, chatList, onDeleteAccount}) {
// 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'
// has no other reader today but gets the same treatment for consistency -- both status badges here
// otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows
@@ -291,6 +291,13 @@ function SettingsList({onToast, onLogout}) {
// blank one.
const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);
const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);
// 清空缓存/清除聊天记录/注销账号三个确认弹层原来是各自独立的 view(hash 路由),route 一跳,
// "设置"这个列表整个连同 Header 一起卸载重挂载——ConfirmSheet 的 .personal-sheet-backdrop 本来是
// 设计成半透明蒙层盖在被弹出页面上方的(backdrop 4:52 rgba,明显是"衬着底下内容看"的视觉语言),
// 但因为底下真的没有任何东西在渲染,视觉上跟直接跳转到一个空白新页面没有区别。改成本组件自己的
// 本地 state,弹层作为兄弟节点叠在列表上方,列表本身不卸载——group-management-view.jsx 的
// OwnerSelect(选择新群主)已经是这个正确模式,这里照抄,不是发明新写法。
const [sheet, setSheet] = React.useState(null); // null | 'cache' | 'history' | 'delete' | 'logout'
React.useEffect(() => {
getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});
getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});
@@ -298,15 +305,10 @@ function SettingsList({onToast, onLogout}) {
const youthEnabled = youthSettings ? youthSettings.enabled : null;
const realnameStatus = realname ? realname.status : null;
const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';
const logout = () => {
if (window.confirm('确定要退出当前登录吗?')) {
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
}
};
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list">
<section>
<MenuRow icon="delete_sweep" title="清除缓存" route="cache-clear-confirm" />
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" route="clear-history-confirm" />
<MenuRow icon="delete_sweep" title="清除缓存" onClick={() => setSheet('cache')} />
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" onClick={() => setSheet('history')} />
<MenuRow icon="password" title="修改密码" route="change-password" />
</section>
<section>
@@ -321,9 +323,14 @@ function SettingsList({onToast, onLogout}) {
用的是同一套 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>
</main></div>;
<section><MenuRow icon="no_accounts" title="注销账号" onClick={() => setSheet('delete')} danger /></section>
<button type="button" className="personal-logout" onClick={() => setSheet('logout')}>退出当前登录</button>
</main>
{sheet === 'cache' ? <CacheClear onToast={onToast} onClose={() => setSheet(null)} /> : null}
{sheet === 'history' ? <ClearHistoryConfirm onToast={onToast} tinode={tinode} chatList={chatList} onClose={() => setSheet(null)} /> : null}
{sheet === 'delete' ? <DeleteAccountConfirm onToast={onToast} onDeleteAccount={onDeleteAccount} onClose={() => setSheet(null)} /> : null}
{sheet === 'logout' ? <LogoutConfirm onToast={onToast} onLogout={onLogout} onClose={() => setSheet(null)} /> : null}
</div>;
}
function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {
@@ -335,11 +342,25 @@ function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger
</section></div>;
}
// Was a bare `window.confirm()` -- the only one of these four confirmation flows not using this
// same ConfirmSheet pattern, an inconsistency with cache/history/delete-account below (their own
// comments describe deliberately moving away from route-per-confirmation dialogs to this in-place
// sheet). A native confirm() is also environment-dependent: some embedded WebViews/kiosk contexts
// disable synchronous dialogs outright (confirm() then returns false unconditionally, silently
// preventing logout with no error shown), which this sheet-based approach doesn't depend on.
function LogoutConfirm({onLogout, onToast, onClose}) {
const confirm = () => {
onClose();
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
};
return <ConfirmSheet icon="logout" title="退出登录" body="确定要退出当前登录吗?" onConfirm={confirm} onCancel={onClose} />;
}
// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:
// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;
// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same
// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.
function CacheClear({onToast}) {
function CacheClear({onToast, onClose}) {
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const [freed, setFreed] = React.useState(0);
@@ -370,25 +391,25 @@ function CacheClear({onToast}) {
return <ConfirmSheet icon={done ? 'check_circle' : 'delete_sweep'} title={done ? '缓存已清理' : '清空缓存'}
body={done ? `已释放约 ${(freed / (1024 * 1024)).toFixed(1)} MB 存储空间。` : '确定清空本地的缓存数据?将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}
confirmLabel={done ? '完成' : '确定'} confirmDanger={!done} busy={busy}
onConfirm={done ? () => navigate('android-settings') : confirm} onCancel={() => navigate('android-settings')} />;
onConfirm={done ? onClose : confirm} onCancel={onClose} />;
}
function ClearHistoryConfirm({tinode, chatList, onToast}) {
function ClearHistoryConfirm({tinode, chatList, onToast, onClose}) {
const [busy, setBusy] = React.useState(false);
const confirm = () => {
if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); navigate('android-settings'); return; }
if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); onClose(); return; }
setBusy(true);
Promise.all(chatList.map(c => {
const topic = tinode.getTopic(c.name);
return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();
})).then(() => {
onToast('聊天记录已清空');
navigate('android-settings');
onClose();
}).finally(() => setBusy(false));
};
return <ConfirmSheet icon="mark_chat_read" title="清除所有聊天记录"
body="确定清除所有聊天记录?此操作不可恢复,但不会影响好友/群聊关系和收藏内容。" busy={busy}
onConfirm={confirm} onCancel={() => navigate('android-settings')} />;
onConfirm={confirm} onCancel={onClose} />;
}
// Changing password has no REST endpoint -- it's native Tinode {acc} protocol
@@ -431,7 +452,7 @@ function ChangePassword({onUpdatePassword, onToast}) {
// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a
// password confirmation step here is a deliberate, conservative frontend-only safeguard given how
// irreversible and consequential this action is, not something the backend requires.
function DeleteAccountConfirm({onDeleteAccount, onToast}) {
function DeleteAccountConfirm({onDeleteAccount, onToast, onClose}) {
const [step, setStep] = React.useState(1);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
@@ -448,10 +469,10 @@ function DeleteAccountConfirm({onDeleteAccount, onToast}) {
// to be disclosed here since it's the only place the user sees before confirming.
return <ConfirmSheet icon="warning" title="注销账号"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?"
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={onClose} />;
}
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
busy={busy} onConfirm={proceed} onCancel={() => navigate('android-settings')}>
busy={busy} onConfirm={proceed} onCancel={onClose}>
<input type="password" autoFocus value={password} onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
</ConfirmSheet>;
}
@@ -470,9 +491,10 @@ function AppVersion({onToast}) {
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */}
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} />
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信;LINK_CONTACT_US 本身也
没有真实地址(原来指向开源 Tinode 项目自己的域名,是品牌泄漏,已改为 null),所以跟
"用户条款"/"隐私协议"两行一样降级成 toast,不指向任何外部地址。 */}
<MenuRow title="联系我们" onClick={() => onToast('联系我们')} />
</section>
</main>
</div>;
@@ -693,9 +715,11 @@ function YouthModeVerify({hashParams, onToast}) {
return <div className="personal-page youth-mode-page">
<Header title={mode === 'enable' ? '开启青少年模式' : '关闭青少年模式'} back={mode === 'enable' ? 'youth-mode-off' : 'youth-mode-on'} />
<main className="youth-password-gate">
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
<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="独立密码" />
<div className="auth-pin-wrap">
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
<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="独立密码" />
</div>
{error ? <div className="auth-field-error">{error}</div> : null}
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
@@ -754,11 +778,8 @@ export default function PersonalSettingsView({view, hashParams, myUserId, tinode
else if (view === 'profile-gender') content = <GenderEditor onToast={setToast} />;
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} />;
else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />;
else if (view === 'clear-history-confirm') content = <ClearHistoryConfirm onToast={setToast} tinode={tinode} chatList={chatList} />;
else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} tinode={tinode} chatList={chatList} onDeleteAccount={onDeleteAccount} />;
else if (view === 'change-password') content = <ChangePassword onToast={setToast} onUpdatePassword={onUpdatePassword} />;
else if (view === 'delete-account-confirm') content = <DeleteAccountConfirm onToast={setToast} onDeleteAccount={onDeleteAccount} />;
else if (view === 'app-version') content = <AppVersion onToast={setToast} />;
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
+11
View File
@@ -164,6 +164,17 @@ class SidepanelView extends React.PureComponent {
myUserId={this.props.myUserId}
onTopicSelected={this.props.onTopicSelected}
onOpenScanner={this.props.onOpenScanner} />
{/* This branch never rendered ErrorPanel at all -- on desktop (non-mobile), opening a real
conversation lands here (view stays 'messages-home' behind the split-view topic pane),
and messages-view.jsx's own ErrorPanel is only rendered on displayMobile. Every
this.props.onError(...) call reachable from an open conversation on desktop (failed
uploads, failed forwards, URL risk blocks, etc.) had no visible outlet at all. */}
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
action={this.props.errorAction}
actionText={this.props.errorActionText}
onClearError={this.props.onError} />
</div>;
}
+150 -36
View File
@@ -140,7 +140,7 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c
'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',
'change-password',
'app-version','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-on','youth-mode-verify',
'notification-permission','permission-settings-reminder','camera-permission-info',
@@ -177,7 +177,8 @@ const CTRL_ERROR_TEXT_CN = {
'authentication required': '请先登录',
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
'policy violation': '当前操作不符合安全策略要求',
'permission denied': '没有权限执行此操作,请重新登录'
'permission denied': '没有权限执行此操作,请重新登录',
'internal error': '服务器繁忙,请稍后重试'
};
function translateCtrlError(err) {
const raw = err && err.message;
@@ -215,6 +216,17 @@ class TinodeWeb extends React.Component {
this.selfRef = React.createRef();
// message-center-view.jsx's conversation-list preview reads topic.latestMessage(), which the
// vendored SDK only ever populates from a topic's own local message cache -- itself only filled
// in once that specific topic has actually been subscribed with a data fetch. me.contacts()
// (resetContactList below) yields the real, already-cached Topic instances for every contact --
// including ones the user has never opened this session -- so most rows fall straight through to
// the '暂无消息' fallback despite `touched`/`seq` on the same object proving real history exists
// server-side. This tracks which topics we've already kicked off a one-shot "just the last
// message" fetch for, so repeated resetContactList() calls (it fires on nearly every {meta}/{pres}
// event, not just once at boot) don't re-subscribe the same topic over and over.
this.previewPrefetchAttempted = new Set();
this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this);
@@ -616,10 +628,16 @@ class TinodeWeb extends React.Component {
}
initFCMessaging() {
const {formatMessage, locale} = this.props.intl;
const {locale} = this.props.intl;
const onError = (msg, err) => {
console.error(msg, err);
this.handleError(formatMessage(messages.push_init_failed), 'err');
// Was routed through the same handleError()/errorText the login/register form's own alert
// renders from -- push notifications are best-effort (the app works fully without them), but
// sharing that channel meant this could cover up a real, concurrent login/register error, or
// (since initFCMessaging is called right as a login request starts) just surface on its own,
// with no login error at all, on a form the user hasn't touched yet. Silently degrading
// (console log + the state resets below, which already disable the desktop-alerts setting)
// is enough; failing to enable push is not something that needs to interrupt the auth flow.
this.setState({firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
}
@@ -750,6 +768,20 @@ class TinodeWeb extends React.Component {
// Handle for hash navigation (hashchange) event: update state.
handleHashRoute() {
const hash = HashNavigation.parseUrlHash(window.location.hash);
// Browser back navigation after a successful login re-renders one of the pre-login views
// (most commonly 'login') while the underlying Tinode connection is still authenticated --
// hash routing and the WS session's lifecycle are independent, so nothing else here would
// otherwise catch this. Landing back on a login/register form while already logged in used to
// let a stale, disabled submit button sit there permanently (loginDisabled is only ever reset
// on a real login attempt's own success/failure, and none happens here), and a form actually
// submitted from this state hit doLogin's/handleNewAccountRequest's own "already authenticated"
// short-circuit -- silently skipping credential validation for login, or surfacing a raw
// untranslated "already authenticated (409)" for registration. Redirecting away before any of
// that code runs removes the only way to reach it.
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
HashNavigation.navigateTo('');
return;
}
// Start with panel parameters.
const newState = {
infoPanel: hash.params.info,
@@ -1073,6 +1105,12 @@ class TinodeWeb extends React.Component {
me.onSubsUpdated = this.tnMeSubsUpdated;
this.setState({
connected: true,
// Only the failure branch of doLogin's promise chain reset this; a success never needed to
// since it navigates away immediately -- until the same Login form could be reached again
// afterwards (browser back) with this still stuck true, permanently disabling the submit
// button. handleHashRoute's redirect above now closes off that specific path, but resetting
// it here too means this can't get stuck regardless of how the form ends up rendered again.
loginDisabled: false,
credMethod: undefined,
credCode: undefined,
credToken: undefined,
@@ -1257,6 +1295,8 @@ class TinodeWeb extends React.Component {
}
});
this.prefetchLatestMessagePreviews(newState.chatList);
const past = new Date(0);
newState.chatList.sort((a, b) => {
// Pinned topics take precedence.
@@ -1270,6 +1310,41 @@ class TinodeWeb extends React.Component {
this.setState(newState);
}
// See the comment on this.previewPrefetchAttempted in the constructor for why this exists.
// seq > 0 is the same 'does this topic actually have any messages at all' signal
// message-center-view.jsx would otherwise have no way to distinguish from 'genuinely empty' --
// skip topics with no history instead of firing a subscribe that would just come back empty.
//
// !this.tinode.isConnected() live repro: resetContactList() fires as soon as the persisted local
// cache restores 'me's last-known contact list (log line "Persistent cache initialized"), which is
// *before* the websocket has even sent {hi} -- every subscribe() call made at that point instantly
// rejects with a 503 "Websocket is not connected". Skip attempting entirely while disconnected
// (rather than catching the failure) so these topics *aren't* added to previewPrefetchAttempted yet
// -- resetContactList() runs again moments later once the real {meta topic:'me'} response arrives
// over the now-live connection, and this gets a genuine second try then.
prefetchLatestMessagePreviews(chatList) {
if (!this.tinode.isConnected()) {
return;
}
for (const c of chatList) {
if (!c.topic || c.seq <= 0 || c.latestMessage() || this.previewPrefetchAttempted.has(c.topic)) {
continue;
}
this.previewPrefetchAttempted.add(c.topic);
// Topic.subscribe(getParams, setParams) -- the query is the *first* argument. Passing it
// second (as setParams) was silently swallowed: the resulting {sub} packet had no `get`
// clause at all (confirmed live: `out: {"sub":{"id":...,"topic":"..."}}`, no `get` key), so
// subscribe succeeded but never actually requested -- let alone received -- any message data.
c.subscribe(c.startMetaQuery().withLaterData(1).build()).catch(err => {
// Leave it in previewPrefetchAttempted either way -- a topic that fails once we're
// definitely connected (e.g. a stale contact the user no longer has access to) isn't going
// to succeed on the next resetContactList() a moment later either, and would otherwise retry
// on every single {meta}/{pres} event for the rest of the session.
console.warn(`Failed to prefetch latest message for '${c.topic}'`, err);
});
}
}
static checkIfPinsUpdated(me, chatList) {
for (let i = 0; i < chatList.length; i++) {
const c = chatList[i];
@@ -1357,12 +1432,22 @@ class TinodeWeb extends React.Component {
}
}
// In mobile view user requested to show sidepanel
// In mobile view user requested to show sidepanel (this is the chat's own "<-" back arrow,
// #hide-message-view in messages-view.jsx). Opening a topic (handleTopicSelected) is a real
// navigateTo() push, so closing it should be the matching pop -- goBack() first, same pattern as
// content-library-view.jsx's Header back button. Before this fix it unconditionally pushed a new
// "no topic" hash entry instead of popping the "topic open" one: open chat A (push) -> tap back
// (push again, but the "chat A open" entry from a moment ago is left sitting right underneath,
// never consumed) -> open chat B (push) -> the system back button then has to be pressed twice to
// leave the chat area at all, and the second press lands back on chat A, not wherever the user
// actually came from before opening any chat.
handleHideMessagesView() {
this.setState({
mobilePanel: 'sidepanel'
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
if (!HashNavigation.goBack()) {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
}
}
// User is sending a message, either plain text or a drafty object, possibly
@@ -1480,8 +1565,21 @@ class TinodeWeb extends React.Component {
if (public_ && public_.photo && public_.photo.ref) {
attachments = [public_.photo.ref];
}
// cred_ is `null` on the username-only registration path (auth-onboarding-view.jsx's Register
// passes it literally as `null` when there's no phone credential to attach, only an invite
// code). tinode-sdk's Tinode.credential(meth, ...) checks `typeof meth == 'object'` to decide
// whether its first argument is really a single {meth,val,resp,params} object -- but
// `typeof null === 'object'` is true in JS, so it unconditionally destructures `{val, params,
// resp, meth} = meth` and crashes with a raw "Cannot destructure property 'val' of 'meth' as
// it is null" the moment it receives a real `null` (as opposed to an object with merely
// undefined fields, which every *login* call site here happens to always pass and which
// destructures fine). This made username-only registration (with or without a valid invite
// code -- client-side validation passes either way before reaching here) always throw and
// never send the {acc} message at all, with the raw JS error text leaking into the "注册"
// button's error banner. Not calling Tinode.credential() at all when there is nothing to
// wrap sidesteps the SDK's bug without patching vendored node_modules code.
return this.tinode.createAccountBasic(login_, password_,
{public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments});
{public: public_, tags: tags_, cred: cred_ ? Tinode.credential(cred_) : undefined, attachments: attachments});
}).then(ctrl => {
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
@@ -1491,7 +1589,15 @@ class TinodeWeb extends React.Component {
this.handleLoginSuccessful(this);
}
}).catch(err => {
this.handleError(err.message, 'err');
// Registration goes over the native WS {acc} protocol, not REST, so im-api.js's
// REASON_MESSAGES translation table (REST-only) never sees this error -- it was showing
// raw SDK text like "internal error (500)" verbatim (confirmed live: retrying a phone
// number that's already registered). CTRL_ERROR_TEXT_CN already translates this same class
// of {ctrl} error for login; reused here with a registration-appropriate fallback instead
// of loginErrorMessage's own (its 401/403 fallback is "账号或密码错误", which doesn't fit a
// failed registration).
const known = translateCtrlError(err);
this.handleError(known || (err && err.message) || '注册失败,请稍后重试', 'err');
});
}
@@ -2476,48 +2582,56 @@ class TinodeWeb extends React.Component {
}
}
handleCallRinging(callTopic, callSeq) {
// All of the call-signaling senders below (ringing/hang-up/offer/ice-candidate/answer) go through
// this one helper rather than calling topic.videoCall() directly. handleCallHangup in particular is
// called from several unmount/teardown paths outside the normal call flow (messages-view.jsx's
// componentWillUnmount and its topic-switch handler in componentSetup both call it via the
// onCallHangup prop, on top of the ring-timeout path and CallInvite's own cleanup here) -- including
// ones that fire *after* a logout has already torn down the connection: handleLogout() calls
// this.tinode.disconnect() and only afterwards resets state to the blank/logged-out screen, and that
// state reset is what unmounts messages-view.jsx and trips its componentWillUnmount -> handleCallHangup
// call. Real repro: click a call button (real or scripted handleStartVideoCall/handleStartAudioCall),
// then immediately log out -- topic.videoCall() ends up calling the vendored SDK's
// Connection.sendText() on an already-closed socket, which throws synchronously ("Websocket is not
// connected"). Thrown from componentWillUnmount, React has no error boundary for it and unmounts the
// entire app to a blank white page. None of these are anything the peer can still receive over a dead
// connection anyway (and for hang-up specifically, the server's own establishment-timeout already
// cleans up an abandoned call-in-progress independently), so skip sending entirely once disconnected
// instead of letting it throw -- the other four signals are currently unreachable in practice (no real
// call can get established while "call invite always 400s" -- see handleCallInvite -- is unfixed) but
// share the identical topic.videoCall() shape and would hit the exact same crash the moment that's
// fixed and real in-progress calls (offer/ice-candidate/answer exchange, or a mid-call logout) become
// possible.
sendCallSignal(callTopic, evt, callSeq, payload) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
if (!topic || !this.tinode.isConnected()) {
return;
}
try {
topic.videoCall(evt, callSeq, payload);
} catch (err) {
console.warn(`Failed to send call signal '${evt}'`, err);
}
}
topic.videoCall('ringing', callSeq);
handleCallRinging(callTopic, callSeq) {
this.sendCallSignal(callTopic, 'ringing', callSeq);
}
handleCallHangup(callTopic, callSeq) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('hang-up', callSeq);
this.sendCallSignal(callTopic, 'hang-up', callSeq);
}
handleCallSendOffer(callTopic, callSeq, sdp) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('offer', callSeq, sdp);
this.sendCallSignal(callTopic, 'offer', callSeq, sdp);
}
handleCallIceCandidate(callTopic, callSeq, candidate) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('ice-candidate', callSeq, candidate);
this.sendCallSignal(callTopic, 'ice-candidate', callSeq, candidate);
}
handleCallSendAnswer(callTopic, callSeq, sdp) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('answer', callSeq, sdp);
handleCallSendAnswer(callTopic, callSeq, sdp) {
this.sendCallSignal(callTopic, 'answer', callSeq, sdp);
}
handleCallClose() {
@@ -2677,7 +2791,7 @@ class TinodeWeb extends React.Component {
'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',
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
'change-password',
'app-version', 'privacy-settings',
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
'notification-permission', 'permission-settings-reminder',
+3 -1
View File
@@ -29,7 +29,9 @@ export default class WallpapersView extends React.PureComponent {
handleTabClick(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
// Same-page tab switch (pattern / image), not a navigation to a different page -- see the same
// fix/comment in new-topic-view.jsx's handleTabClick.
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({tab: e.currentTarget.dataset.id});
}
+72 -17
View File
@@ -22,6 +22,14 @@ const navigate = (view, params) => {
HashNavigation.navigateTo(hash);
};
// Same bug/fix as content-library-view.jsx's navigateReplace: DailyReportList/MonthlyReportList's
// 我的日报|月报 <-> 团队统计 switch is a same-page tab toggle (same component, just a `team` boolean),
// not a navigation to a different page -- using navigate() here pushed a history entry per click, so
// switching back and forth a few times made the system back button (and this app's own Header back
// button, gated on HashNavigation.goBack()) cycle through past toggle states instead of leaving the
// report page. replaceState-based navigateReplace keeps the hash in sync without that side effect.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
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',
@@ -166,7 +174,7 @@ function Workbench({onToast}) {
</button>)}</section>
</main><nav className="workplace-bottom-nav" aria-label="主导航">
<button type="button" className="active"><Icon>dashboard</Icon><span>工作台</span></button>
<button type="button" onClick={() => navigate('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button>
<button type="button" onClick={() => navigateReplace('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button>
</nav></div>;
}
@@ -302,10 +310,21 @@ function MeetingCard({meeting, myUserId, onRespond, onToast}) {
{!isOrganizer ? (
meeting.my_response && meeting.my_response !== 'pending' ?
<footer><button type="button" className="meeting-responded" disabled>{RESPONSE_LABEL[meeting.my_response] || meeting.my_response}</button></footer> :
<footer className="meeting-response">
// Live-repro'd (round 12): this branch used to render regardless of `permissions.can_respond`,
// so a canceled meeting the viewer never responded to (my_response still 'pending', a normal
// state for a canceled invite -- canceling doesn't force every invitee to respond first) kept
// showing live-looking 拒绝/接受 buttons here. Clicking either got a 403 PERMISSION_DENIED from
// PUT .../my-response (confirmed against a real canceled meeting, mtg_0w2TnfQiqaMwy8Bj) --
// the request body/version were fine, the backend just correctly refuses to accept a response
// once a meeting can no longer be responded to. MeetingDetail already gates its own copy of
// these same two buttons on this exact flag (line ~406 below); this list card independently
// duplicates the buttons but had never adopted the same guard. Requirements 9.4 also calls for
// expired meetings to become read-only history, which this same flag should cover -- can_respond
// is a general "still actionable" signal, not canceled-specific.
(meeting.permissions && meeting.permissions.can_respond ? <footer className="meeting-response">
<button type="button" disabled={busy} onClick={() => respond('declined')}>拒绝</button>
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
</footer>
</footer> : null)
) : null}
</article>;
}
@@ -381,8 +400,15 @@ function MeetingDetail({hashParams, onToast}) {
};
const respond = response => {
setBusy(true);
// Live-repro'd (round 14): RESPONSE_LABEL's 'accepted'/'declined' entries are already prefixed
// ("已接受"/"已拒绝") for use as the disabled-button/participant-badge label elsewhere on this
// page -- reusing it here with an extra literal "已" in front produced a toast reading "已已接受"/
// "已已拒绝" (and a slightly-off "已待定" for tentative, since that entry has no "已" to double up
// with). MeetingCard's own respond() (this same action, reached from the list card instead of this
// detail page) already had correct, un-doubled wording for all three -- reuse that instead of
// re-deriving new strings from RESPONSE_LABEL.
getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version))
.then(_ => { onToast(RESPONSE_LABEL[response] ? `已${RESPONSE_LABEL[response]}` : '已回应'); load(); })
.then(_ => { onToast(response === 'accepted' ? '已接受会议邀请' : response === 'declined' ? '已拒绝会议邀请' : '已标记为待定'); load(); })
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
};
@@ -412,7 +438,14 @@ function MeetingDetail({hashParams, onToast}) {
</article>
{isOrganizer ? <section className="meeting-status">
<h2>参会确认</h2>
<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>
{/* counts.tentative was already computed above but never rendered here -- live-repro'd
(round 14): after a participant responds "待定" via MeetingDetail's own respond('tentative')
button (or the equivalent list-card flow), this summary's three numbers (已接受/待响应/
已拒绝) silently stopped summing to participant_count with no visible explanation, because
the fourth bucket their response actually landed in was computed but dropped on the floor.
Appended as a 4th stat (order of the original three left untouched) so every response state
has a visible home; grid-template-columns bumped from 3 to 4 to match (see base.css). */}
<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><span><b>{counts.tentative}</b><small>待定</small></span></div>
<footer>
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
{/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等,
@@ -695,7 +728,7 @@ function DailyReportCard({report, names, team}) {
<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>
<button type="button" onClick={() => navigate('daily-report-detail', {report_id: report.report_id})}>查看详情<Icon>chevron_right</Icon></button></footer>
<button type="button" onClick={() => navigate('daily-report-detail', {report_id: report.report_id, team: team ? '1' : ''})}>查看详情<Icon>chevron_right</Icon></button></footer>
</article>;
}
@@ -735,9 +768,22 @@ function DailyReportList({team, myUserId, onToast}) {
return hay.includes(query);
});
// TenantDailyReportRemind (server/db/mysql/tenant_adapter.go) filters *out of a candidate list the
// caller supplies* down to whoever hasn't submitted today -- it doesn't compute "everyone" itself,
// and rejects an empty user_ids with 400. GET /organization/members with no q/department_id already
// returns every member the caller can see (paginated), which is exactly the candidate pool this
// button's "提醒未提交成员" wording implies -- so page through it here rather than reporting this as
// a backend gap, matching how getOrganizationMembers is already used for the meeting attendee picker.
const remindMissing = () => {
sendDailyReminders(toDateInputValue(new Date())).then(_ => onToast('已提醒未提交成员'))
.catch(err => onToast(err.message || '提醒失败'));
const collectAllMembers = (cursor, acc) =>
getOrganizationMembers({cursor, limit: 100}).then(d => {
const next = acc.concat((d.items || []).map(m => m.user_id));
return d.has_more && d.next_cursor ? collectAllMembers(d.next_cursor, next) : next;
});
collectAllMembers('', []).then(userIds => {
if (!userIds.length) { onToast('组织架构里没有可提醒的成员'); return; }
return sendDailyReminders(toDateInputValue(new Date()), userIds).then(_ => onToast('已提醒未提交成员'));
}).catch(err => onToast(err.message || '提醒失败'));
};
return <div className="workplace-page report-list-page">
@@ -745,7 +791,7 @@ function DailyReportList({team, myUserId, onToast}) {
<main>
{team && stats ? <section className="report-statistics">
<header><span><small>{formatMeetingDate(new Date())}</small><strong>全公司</strong></span>
<button type="button" onClick={() => navigate('daily-report-mine')}>我的日报</button></header>
<button type="button" onClick={() => navigateReplace('daily-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>
@@ -755,7 +801,7 @@ function DailyReportList({team, myUserId, onToast}) {
<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>
<button type="button" onClick={() => navigateReplace('daily-report-team')}>团队统计</button>
</nav> : null}
<section className="report-cards">
{reports == null ? <p className="organization-loading">加载中…</p> :
@@ -768,6 +814,11 @@ function DailyReportList({team, myUserId, onToast}) {
function DailyReportDetail({hashParams, myUserId, onToast}) {
const reportId = hashParams && hashParams.report_id;
// DailyReportCard renders identically for DailyReportList's team=true (团队统计) and team=false
// (我的日报) modes and always links here -- backTarget hardcoded to daily-report-mine used to send
// a manager who'd been browsing 团队统计 back to their own report list instead of the team roster
// they'd actually come from.
const backTarget = hashParams && hashParams.team ? 'daily-report-team' : 'daily-report-mine';
const [report, setReport] = React.useState(() => (reportId && readCachedData('daily-report:' + reportId)) || null);
const [loadError, setLoadError] = React.useState(false);
const names = useUserNames(report ? [report.author_id] : [], myUserId);
@@ -779,9 +830,9 @@ function DailyReportDetail({hashParams, myUserId, onToast}) {
setReport(readCachedData('daily-report:' + reportId) || null);
getDailyReport(reportId).then(r => { writeCachedData('daily-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '日报加载失败'); setLoadError(true); });
}, [reportId]);
if (!report) return <div className="workplace-page"><Header title="查看详情" back="daily-report-mine" /><main><p className="organization-loading">{loadError ? '日报加载失败' : '加载中…'}</p></main></div>;
if (!report) return <div className="workplace-page"><Header title="查看详情" back={backTarget} /><main><p className="organization-loading">{loadError ? '日报加载失败' : '加载中…'}</p></main></div>;
return <div className="workplace-page"><Header title="查看详情" back="daily-report-mine" />
return <div className="workplace-page"><Header title="查看详情" back={backTarget} />
<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>
@@ -938,7 +989,7 @@ function MonthlyReportList({team, myUserId, onToast}) {
<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>
<button type="button" onClick={() => navigateReplace('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>
@@ -947,13 +998,13 @@ function MonthlyReportList({team, myUserId, onToast}) {
<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>
<button type="button" onClick={() => navigateReplace('monthly-report-team')}>团队统计</button>
</nav> : null}
<section className="report-cards">
{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})}>
onClick={() => navigate((r.action || (r.status === 'submitted' ? 'view' : 'edit')) === 'view' ? 'monthly-report-detail' : 'monthly-report-editor', {report_id: r.report_id, team: team ? '1' : ''})}>
<span className="report-month"><Icon>calendar_month</Icon></span>
<span><strong>{team ? (names[r.author_id] || r.author_id) : formatMonthLabel(r.month)}</strong>
<small>{team ? formatMonthLabel(r.month) : '工作总结与下月计划'}</small></span>
@@ -967,6 +1018,10 @@ function MonthlyReportList({team, myUserId, onToast}) {
function MonthlyReportDetail({hashParams, myUserId, onToast}) {
const reportId = hashParams && hashParams.report_id;
// Same fix as DailyReportDetail's backTarget: the list row that links here renders identically for
// team=true (团队统计) and team=false (我的月报), so a hardcoded 'monthly-report-mine' sent a manager
// who'd been browsing 团队统计 back to their own list instead of the team roster they came from.
const backTarget = hashParams && hashParams.team ? 'monthly-report-team' : 'monthly-report-mine';
const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null);
const [loadError, setLoadError] = React.useState(false);
const [reopening, setReopening] = React.useState(false);
@@ -977,7 +1032,7 @@ 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-mine" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
if (!report) return <div className="workplace-page"><Header title="查看详情" back={backTarget} /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
// 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
@@ -991,7 +1046,7 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
.finally(() => setReopening(false));
};
return <div className="workplace-page"><Header title="查看详情" back="monthly-report-mine" />
return <div className="workplace-page"><Header title="查看详情" back={backTarget} />
<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>
+5 -1
View File
@@ -240,6 +240,10 @@ class AudioPlayer extends React.PureComponent {
this.audioPlayer.pause();
this.setState({playing: false});
} else if (this.audioPlayer.readyState >= 2) {
if (activeAudioPlayer && activeAudioPlayer !== this) {
activeAudioPlayer.pauseForOtherPlayer();
}
activeAudioPlayer = this;
this.audioPlayer.play();
this.setState({playing: true}, this.visualize);
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
@@ -284,7 +288,7 @@ class AudioPlayer extends React.PureComponent {
:
<>
{play}
<div>
<div className="playback-row">
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
</div>
+10 -3
View File
@@ -424,11 +424,18 @@ class AudioRecorder extends React.PureComponent {
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
if (this.props.holdMode) {
// docs/images/内容沟通对话3-语音录音中.png shows a centered floating card (icon + waveform +
// hint), not an inline pill docked in the composer row -- reuses .compose-record-overlay,
// the position:fixed/centered-card CSS that message-composer-view.jsx's (non-wired, demo-only)
// VoiceOverlay already has for this exact look, rather than inventing a second copy of it.
// The waveform itself stays the real canvas visualization (real amplitude data via
// visualize()), not the demo's static bar list -- only the container and text markup change.
return (
<div className={'audio audio-hold' + (this.props.cancelling ? ' cancelling' : '')}>
<div className={'compose-record-overlay' + (this.props.cancelling ? ' cancel' : '')} role="status">
<i className="material-icons">{this.props.cancelling ? 'delete_outline' : 'mic'}</i>
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
<div className="duration">{this.state.duration}</div>
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div>
<strong>{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong>
<small>{this.state.duration}</small>
</div>
);
}
+1 -1
View File
@@ -133,7 +133,7 @@ export default class CallIncoming extends React.Component {
topic={this.props.topic}
title={this.state.fullName} />
</div>
<h1>{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
<h1><span>{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}</span>
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></h1>
<p>{isAudioOnly ? '对方邀请你语音通话...' : '对方邀请你视频通话...'}</p>
{ringing && <span className="call-ringing-dots"><i /><i /><i /></span>}
+1 -1
View File
@@ -19,7 +19,7 @@ export default class MetaMessage extends React.PureComponent {
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
bubbleClass += ' date';
bubbleClass += ' deleted';
}
if (!content) {
+4 -1
View File
@@ -38,7 +38,10 @@ class ReceivedMarker extends React.PureComponent {
}
const icon = deliveryMarker(this.props.received);
const marker = icon ? <i className={'material-icons small ' + icon.color}>{icon.name}</i> : null;
// icon.color is only set for the failed/read cases (deliveryMarker's other {name} results omit
// the key entirely) -- string-concatenating it unconditionally put the literal text "undefined"
// into the className for sending/sent/received messages.
const marker = icon ? <i className={'material-icons small ' + (icon.color || '')}>{icon.name}</i> : null;
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
return (
+20 -7
View File
@@ -30,12 +30,20 @@ const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '
// 互相 import,就近各自维护一份小常量)。收藏摘要要优先读 snapshot.content.txt 这个结构化字段,
// 不能像最初那版一样直接展示 snapshot.text——那是整条 content 原样 JSON.stringify 的结果
// (例如 {"txt":"..."}),不是给人看的,之前联调时真被这个坑绊了一次才发现。
const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]', text: '[文本]'};
function favoriteSummary(item) {
const content = item.snapshot && item.snapshot.content;
if (content && typeof content.txt === 'string' && content.txt) {
return content.txt;
// image/video/voice favorites' snapshot.content.txt is a placeholder single space in this
// project's convention (confirmed via a real GET /favorites response) -- reading it as the
// summary showed a blank line instead of a type label for these three types.
if (item.type === 'image' || item.type === 'video' || item.type === 'voice') {
return FAVORITE_TYPE_LABEL[item.type];
}
const content = item.snapshot && item.snapshot.content;
// A text favorite's snapshot.content is a plain string, not a {txt:...} Drafty object --
// content.txt on it is undefined, which used to fall through all the way to the internal
// item.type value ("text") being shown to the user verbatim.
if (typeof content === 'string' && content.trim()) return content;
if (content && typeof content.txt === 'string' && content.txt.trim()) return content.txt;
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
}
const RECENT_EMOJI_KEY = 'im-recent-emoji';
@@ -220,9 +228,14 @@ class SendMessage extends React.PureComponent {
} else if (prevProps.initMessage != this.props.initMessage) {
const msg = this.props.initMessage || '';
this.setState({message: msg}, _ => {
// If there is text, scroll to bottom and set caret to the end.
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
this.messageEditArea.setSelectionRange(msg.length, msg.length);
// messageEditArea can be null here: initMessage (draft/edit content) can change while the
// textarea itself is unmounted, e.g. noInput during an active forward, or disabled while
// ACS is still resolving right after a topic switch.
if (this.messageEditArea) {
// If there is text, scroll to bottom and set caret to the end.
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
this.messageEditArea.setSelectionRange(msg.length, msg.length);
}
});
}
if (prevProps.reply != this.props.reply) {
+2
View File
File diff suppressed because one or more lines are too long
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
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
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
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
File diff suppressed because one or more lines are too long
+802 -605
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
+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
+111 -106
View File
@@ -20,15 +20,17 @@ __webpack_require__.r(__webpack_exports__);
/* harmony import */ var _widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../widgets/contact-list.jsx */ "./src/widgets/contact-list.jsx");
/* harmony import */ var _widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ../widgets/error-panel.jsx */ "./src/widgets/error-panel.jsx");
/* harmony import */ var _widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ../widgets/group-manager.jsx */ "./src/widgets/group-manager.jsx");
/* harmony import */ var _widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ../widgets/menu-cancel.jsx */ "./src/widgets/menu-cancel.jsx");
/* harmony import */ var _widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ../widgets/permissions-editor.jsx */ "./src/widgets/permissions-editor.jsx");
/* harmony import */ var _widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ../widgets/show-qrcode.jsx */ "./src/widgets/show-qrcode.jsx");
/* harmony import */ var _topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ./topic-common-view.jsx */ "./src/views/topic-common-view.jsx");
/* harmony import */ var _widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ../widgets/topic-security.jsx */ "./src/widgets/topic-security.jsx");
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
/* harmony import */ var _lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(/*! ../lib/strformat.js */ "./src/lib/strformat.js");
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
/* harmony import */ var _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ../lib/navigation.js */ "./src/lib/navigation.js");
/* harmony import */ var _widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ../widgets/menu-cancel.jsx */ "./src/widgets/menu-cancel.jsx");
/* harmony import */ var _widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ../widgets/permissions-editor.jsx */ "./src/widgets/permissions-editor.jsx");
/* harmony import */ var _widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ../widgets/show-qrcode.jsx */ "./src/widgets/show-qrcode.jsx");
/* harmony import */ var _topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ./topic-common-view.jsx */ "./src/views/topic-common-view.jsx");
/* harmony import */ var _widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ../widgets/topic-security.jsx */ "./src/widgets/topic-security.jsx");
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
/* harmony import */ var _lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(/*! ../lib/strformat.js */ "./src/lib/strformat.js");
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
@@ -222,8 +224,8 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
newState.modeGiven2 = user2.acs.getGiven();
newState.modeWant2 = user2.acs.getWant();
} else {
newState.modeGiven2 = _config_js__WEBPACK_IMPORTED_MODULE_14__.NO_ACCESS_MODE;
newState.modeWant2 = _config_js__WEBPACK_IMPORTED_MODULE_14__.NO_ACCESS_MODE;
newState.modeGiven2 = _config_js__WEBPACK_IMPORTED_MODULE_15__.NO_ACCESS_MODE;
newState.modeWant2 = _config_js__WEBPACK_IMPORTED_MODULE_15__.NO_ACCESS_MODE;
}
} else {
topic.subscribers(sub => {
@@ -250,11 +252,11 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
sharer: acs && acs.isSharer(),
deleter: acs && acs.isDeleter(),
muted: acs && acs.isMuted(),
fullName: isSelf ? props.intl.formatMessage(messages.self_topic_name) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__.truncateString)(topic.public && topic.public.fn, _config_js__WEBPACK_IMPORTED_MODULE_14__.MAX_TITLE_LENGTH),
description: isSelf ? props.intl.formatMessage(messages.self_topic_comment) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__.truncateString)(topic.public && topic.public.note, _config_js__WEBPACK_IMPORTED_MODULE_14__.MAX_TOPIC_DESCRIPTION_LENGTH),
avatar: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_15__.makeImageUrl)(topic.public ? topic.public.photo : null),
fullName: isSelf ? props.intl.formatMessage(messages.self_topic_name) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__.truncateString)(topic.public && topic.public.fn, _config_js__WEBPACK_IMPORTED_MODULE_15__.MAX_TITLE_LENGTH),
description: isSelf ? props.intl.formatMessage(messages.self_topic_comment) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__.truncateString)(topic.public && topic.public.note, _config_js__WEBPACK_IMPORTED_MODULE_15__.MAX_TOPIC_DESCRIPTION_LENGTH),
avatar: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_16__.makeImageUrl)(topic.public ? topic.public.photo : null),
trustedBadges: badges,
private: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__.truncateString)(topic.private && topic.private.comment, _config_js__WEBPACK_IMPORTED_MODULE_14__.MAX_TITLE_LENGTH),
private: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__.truncateString)(topic.private && topic.private.comment, _config_js__WEBPACK_IMPORTED_MODULE_15__.MAX_TITLE_LENGTH),
archived: topic.isArchived(),
address: topic.name,
groupTopic: topic.isGroupType(),
@@ -425,7 +427,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
this.setState({
toast: ''
});
}, _config_js__WEBPACK_IMPORTED_MODULE_14__.TOAST_DURATION);
}, _config_js__WEBPACK_IMPORTED_MODULE_15__.TOAST_DURATION);
});
}
handleShare(e) {
@@ -449,6 +451,9 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
});
}
handleBackNavigate() {
if (_lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].goBack()) {
return;
}
const args = (this.props.panel || 'info').split('/');
if (args[0] == 'info') {
this.props.onNavigate(null);
@@ -503,28 +508,28 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view]) || messages['info']);
const topic = this.props.tinode.getTopic(this.state.topic);
const alias = topic && topic.alias();
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
id: "info-view",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: `toast${this.state.toast ? ' show' : ''}`,
children: this.state.toast
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "caption-panel",
id: "info-caption-panel",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-title",
id: "info-title",
children: panelTitle
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_9__["default"], {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_10__["default"], {
onCancel: this.handleBackNavigate
}, void 0, false)
}, void 0, false)]
}, void 0, true), this.props.displayMobile ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__["default"], {
}, void 0, true), this.props.displayMobile ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__["default"], {
level: this.props.errorLevel,
text: this.props.errorText,
onClearError: this.props.onError
}, void 0, false) : null, view == 'members' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__["default"], {
}, void 0, false) : null, view == 'members' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__["default"], {
tinode: this.props.tinode,
members: this.state.contactList,
requiredMember: this.props.myUserId,
@@ -533,7 +538,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
contacts: this.props.searchableContacts,
onCancel: this.handleBackNavigate,
onSubmit: this.handleMemberUpdateRequest
}, void 0, false) : view == 'perm' && args.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_10__["default"], {
}, void 0, false) : view == 'perm' && args.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_11__["default"], {
tinode: this.props.tinode,
mode: this.state.editedPermissions,
compare: this.state.immutablePermissions,
@@ -545,7 +550,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
userAvatar: this.state.userPermissionsAvatar,
onSubmit: mode => this.handlePermissionsChanged(args[0], mode),
onCancel: this.handleBackNavigate
}, void 0, false) : view == 'general' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_12__["default"], {
}, void 0, false) : view == 'general' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_13__["default"], {
tinode: this.props.tinode,
topic: this.props.topic,
reqCredMethod: this.props.reqCredMethod,
@@ -555,7 +560,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
onCredDelete: this.props.onCredDelete,
onUpdateTopicDesc: this.props.onTopicDescUpdateRequest,
onError: this.props.onError
}, void 0, false) : view == 'security' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_13__["default"], {
}, void 0, false) : view == 'security' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_14__["default"], {
topic: this.props.topic,
owner: this.state.owner,
admin: this.state.admin,
@@ -578,140 +583,140 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
onReportTopic: this.props.onReportTopic,
onLaunchPermissionsEditor: this.handleLaunchPermissionsEditor,
onNavigate: this.props.onNavigate
}, void 0, false) : view == 'qrcode' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_11__["default"], {
}, void 0, false) : view == 'qrcode' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_12__["default"], {
uri: tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Tinode.URI_TOPIC_ID_PREFIX + this.props.topic,
onCancel: this.handleBackNavigate
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
id: "info-view-content",
className: "scrollable-panel",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-column",
children: [!this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
children: [!this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
className: "flat-button float-right",
onClick: e => {
e.preventDefault();
this.props.onNavigate('general');
},
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "edit"
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "button_edit",
defaultMessage: "Edit",
description: "Call to action [Edit]"
}, void 0, false)]
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("center", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("center", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
tinode: this.props.tinode,
avatar: this.state.avatar,
readOnly: true,
uid: this.props.topic,
title: this.state.fullName
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "group",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_topic_name",
defaultMessage: "Name",
description: "Label for editing topic name"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "large ellipsized",
children: [this.state.fullName, "\xA0", this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: [this.state.fullName, "\xA0", this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "podcasts"
}, void 0, false) : null]
}, void 0, true)]
}, void 0, true), this.state.private ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true), this.state.private ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "group",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_private",
defaultMessage: "Private comment",
description: "Label for editing 'private'"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "large ellipsized",
children: this.state.private
}, void 0, false)]
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_user_id",
defaultMessage: "ID:",
description: "Label for user address (ID)"
}, void 0, false)
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("tt", {
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("tt", {
children: this.state.address
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
style: {
marginLeft: 'auto'
},
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
onClick: e => {
this.handleCopyToClipboard(e, this.state.address);
},
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "content_copy"
}, void 0, false)
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
onClick: this.handleShowQRCode,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "qr_code"
}, void 0, false)
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
onClick: this.handleShare,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "share"
}, void 0, false)
}, void 0, false), "\xA0"]
}, void 0, true)]
}, void 0, true), alias ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true), alias ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_alias",
defaultMessage: "Alias:",
description: "Label for user or topic alias"
}, void 0, false)
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("tt", {
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("tt", {
children: alias
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
style: {
marginLeft: 'auto'
},
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
onClick: e => {
this.handleCopyToClipboard(e, alias);
},
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "content_copy"
}, void 0, false)
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
style: {
opacity: 0
@@ -720,125 +725,125 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}, void 0, false), "\xA0"]
}, void 0, true)]
}, void 0, true) : null]
}, void 0, true) : null, this.state.trustedBadges.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true) : null, this.state.trustedBadges.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "group",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_badge_list_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_badge_list_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
trustedBadges: this.state.trustedBadges
}, void 0, false)
}, void 0, false) : null, this.state.groupTopic && topic.subcnt > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false) : null, this.state.groupTopic && topic.subcnt > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "group",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_subscriber_count",
defaultMessage: "Subscribers:",
description: "Label for the count of channel subscribers"
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_member_count",
defaultMessage: "Members:",
description: "Label for the count of group topic members"
}, void 0, false)
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedNumber, {
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedNumber, {
value: topic.subcnt
}, void 0, false)]
}, void 0, true) : null, this.state.description ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true) : null, this.state.description ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "group",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_description",
defaultMessage: "Description",
description: "Label for description of user or topic"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
children: this.state.description
}, void 0, false)]
}, void 0, true) : null]
}, void 0, true), !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true), !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "hr"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_muting_topic",
defaultMessage: "Muted:",
description: "Label for Muting/unmuting the topic"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
name: "P",
checked: this.state.muted,
onChange: this.handleMuted
}, void 0, false)]
}, void 0, true)]
}, void 0, true) : null, this.state.archived ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true) : null, this.state.archived ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_unarchive_topic",
defaultMessage: "Archived:",
description: "Label for unarchiving the topic"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
name: "archived",
checked: true,
onChange: this.handleUnarchive
}, void 0, false)]
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "hr"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
className: "flat-button",
onClick: e => {
e.preventDefault();
this.props.onNavigate('security');
},
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "security"
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "button_security",
defaultMessage: "Security",
description: "Navigaton button for security panel."
}, void 0, false)]
}, void 0, true)
}, void 0, false)]
}, void 0, true) : null, this.state.groupTopic && this.state.sharer ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, true) : null, this.state.groupTopic && this.state.sharer ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "hr"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_group_members",
defaultMessage: "Group members:",
description: "Section title or label"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
href: "#",
className: "flat-button",
onClick: this.handleShowAddMembers,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
className: "material-icons",
children: "person_add"
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "button_add_members",
defaultMessage: "Add members",
description: "Flat button [Add members] (to topic)"
}, void 0, false)]
}, void 0, true)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "group_has_no_members",
defaultMessage: "No members",
description: "Shown in place of group members",
children: no_members => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
children: no_members => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
tinode: this.props.tinode,
contacts: this.state.contactList,
myUserId: this.props.myUserId,
File diff suppressed because one or more lines are too long
+33 -11
View File
@@ -323,6 +323,24 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}
this.mountDnDEvents(this.dndRef);
this.componentSetup({}, {});
if (this.props.jumpToSeq) {
const seq = parseInt(this.props.jumpToSeq, 10);
const topicObj = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
const attemptJump = () => this.jumpToMessage(seq);
if (topicObj && !topicObj.isSubscribed()) {
let tries = 0;
const waitForSubscription = () => {
if (topicObj.isSubscribed() || ++tries > 25) {
attemptJump();
} else {
window.setTimeout(waitForSubscription, 200);
}
};
waitForSubscription();
} else {
attemptJump();
}
}
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.getBootstrap)().then(data => {
this.setState({
messagePolicy: data.message_policy || null
@@ -1016,7 +1034,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
menuItems.push('menu_item_copy', 'menu_item_favorite');
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && recallWindow > 0 && params.timestamp && params.timestamp.getTime() > new Date().getTime() - recallWindow * 1000) {
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && params.seq < tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.LOCAL_SEQID && recallWindow > 0 && params.timestamp && params.timestamp.getTime() > new Date().getTime() - recallWindow * 1000) {
menuItems.push('menu_item_recall');
}
if (topic.isSelfType()) {
@@ -1969,7 +1987,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
const messageNodes = [];
let previousFrom = null;
let prevDate = null;
let chatBoxClass = null;
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
topic.messages((msg, prev, next, i) => {
let nextFrom = next ? next.from || 'chan' : null;
let sequence = 'single';
@@ -1994,7 +2012,6 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
userName = user.public.fn;
userAvatar = (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_17__.makeImageUrl)(user.public.photo);
}
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
const ref = this.getOrCreateMessageRef(msg.seq);
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
if (!replyToSeq || isNaN(replyToSeq)) {
@@ -3175,7 +3192,7 @@ class MetaMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
className: "gray",
children: this.props.deletedText || '消息已删除'
}, void 0, false);
bubbleClass += ' date';
bubbleClass += ' deleted';
}
if (!content) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.Fragment, {
@@ -3449,7 +3466,7 @@ class ReceivedMarker extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureC
}
const icon = (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_3__.deliveryMarker)(this.props.received);
const marker = icon ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("i", {
className: 'material-icons small ' + icon.color,
className: 'material-icons small ' + (icon.color || ''),
children: icon.name
}, void 0, false) : null;
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
@@ -3497,13 +3514,16 @@ const FAVORITE_TYPE_LABEL = {
video: '[视频]',
voice: '[语音]',
file: '[文件]',
link: '[链接]'
link: '[链接]',
text: '[文本]'
};
function favoriteSummary(item) {
const content = item.snapshot && item.snapshot.content;
if (content && typeof content.txt === 'string' && content.txt) {
return content.txt;
if (item.type === 'image' || item.type === 'video' || item.type === 'voice') {
return FAVORITE_TYPE_LABEL[item.type];
}
const content = item.snapshot && item.snapshot.content;
if (typeof content === 'string' && content.trim()) return content;
if (content && typeof content.txt === 'string' && content.txt.trim()) return content.txt;
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
}
const RECENT_EMOJI_KEY = 'im-recent-emoji';
@@ -3668,8 +3688,10 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
this.setState({
message: msg
}, _ => {
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
this.messageEditArea.setSelectionRange(msg.length, msg.length);
if (this.messageEditArea) {
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
this.messageEditArea.setSelectionRange(msg.length, msg.length);
}
});
}
if (prevProps.reply != this.props.reply) {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -46,7 +46,7 @@ class WallpapersView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureC
}
handleTabClick(e) {
e.preventDefault();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({
tab: e.currentTarget.dataset.id
});
File diff suppressed because one or more lines are too long
+9 -7
View File
@@ -352,15 +352,17 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
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", {
className: 'compose-record-overlay' + (this.props.cancelling ? ' cancel' : ''),
role: "status",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("i", {
className: "material-icons",
children: this.props.cancelling ? 'delete_outline' : 'mic'
}, void 0, false), 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",
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("strong", {
children: this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("small", {
children: this.state.duration
}, void 0, false)]
}, void 0, true);
}
File diff suppressed because one or more lines are too long