Compare commits

..
40 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 0d32569633 修复通讯录"新朋友"两处视觉问题:未读红点数字被挤出圆圈、拒绝/通过验证按钮在移动端换行
现象1:新朋友入口的红色未读数徽标(.people-entry-list em)只显示一个纯红点,数字看不见。
现象2:待处理好友申请行的"拒绝"/"通过验证"两个按钮上下堆叠换行,而不是同一行并排。

根因1:全局 button{line-height:2.2rem} 被 <em> 徽标继承,数字所在的匿名行盒远高于
1.2rem 的圆圈本身,place-items:center 只能让整个超高行盒居中,数字被推出圆心、
甚至溢出到按钮白色背景上(白字白底=看起来像空心圆点)。这跟本工程早前那轮全局
line-height 排查是同一类问题(01325ad0),.messages-conversation-meta b 当时已经
用 line-height:1 修过,.people-entry-list em 这条当时漏改了。

根因2:css/base.css 里 @media(max-width:640px) 断点下有一条
.people-request-list article > span, .group-request-list article > span
{flex-direction:column} 的覆盖规则,把默认的横向flex行强制改成纵向堆叠。核对两处
按钮组的实际内容(拒绝/通过验证、拒绝/确定,均为2个短文本按钮)后确认这条覆盖没有
任何布局必要性,纯属误加;因为 im_webapp 主要是移动端场景,这个断点几乎总命中,
所以两个页面的按钮实际上一直都在换行。

修法:给 .people-entry-list em 补上 line-height:1;删除上述@media块里这条多余的
flex-direction:column(连带align-items:flex-start)覆盖规则,让两个列表都退回默认
的横向排列+垂直居中。

验证:本地起dev-proxy,用真实登录账号在通讯录页面注入与真实DOM结构/class名完全
一致的测试节点核对渲染效果——红点数字正确居中显示,两个按钮同一行并排显示,
与预期一致。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 01:30:48 +08:00
Your NameandClaude Sonnet 5 31f2b4afea 修复"我的收藏"批量删除仍用原生 window.confirm(),是本工程已确立模式里同一类问题的第三处
现象:跟已经修过的两处同一类问题(46a98782"退出登录"、4773ee5f"解散群组")一样,
content-library-view.jsx 的收藏批量删除也还在用裸 window.confirm(),4773ee5f 的提交
说明里已经点名记录过这一处、留给第七章处理。

根因:window.confirm() 与本工程其它确认弹层的视觉不一致,且部分内嵌 WebView/kiosk
环境会直接禁用同步对话框(confirm() 恒返回 false,删除被静默拦下、用户毫无感知)。

修法:改成两段式 state(点"删除"先进"确认中"态渲染弹层,弹层里的"确定"才真正调用
batchDeleteFavorites),新增 .library-sheet-backdrop/.library-confirm-sheet 样式,
数值抄自本文件已有的 .personal-*/.group-* 同款弹层(46a98782/4773ee5f),保持视觉
语言一致。

验证:真实浏览器注册全新测试账号,在一个真实群聊里发送并收藏两条消息,进入"我的
收藏"多选模式,选中两项后:先测"点删除→点取消"(弹层消失、两项收藏原样保留、未
发出任何 batch-delete 请求);再测"点删除→点确定"(真实 POST .../favorites/
batch-delete 200 成功、两项 ok:true、列表正确清空为"暂无此类收藏"、toast 提示
"已删除所选收藏")。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 17:55:48 +08:00
Your Name c2e07a89b8 清理死代码:sidepanel-view.jsx里被chapterOneViews短路掉的旧版登录/注册/找回密码分支
不是功能修复,纯清理。sidepanel-view.jsx的render()方法开头(chapterOneViews数组
含'login'/'register'/'reset'等)已经对这些view值提前return真实对接后端的
AuthOnboardingView。但同一个render()方法下半部分的三元表达式链里,还留着一份基于
view==='login'/'register'/'reset'的旧分支,分别渲染LoginView/CreateAccountView/
PasswordResetView——由于上面的提前return,这三个view值永远不可能落到这里,是无法
触达的死代码。

确认方式:全仓库搜索确认SidepanelView只有tinode-web.jsx一处渲染入口,其view prop
(state={this.state.sidePanelSelected})只能来自handleHashRoute对SIDE_PANEL_VIEWS
白名单的校验结果——'login'/'register'/'reset'确实在这个白名单里(否则连
sidePanelSelected都设置不了),但chapterOneViews这个子集在render()内部的检查顺序
必然先于旧分支执行,所以旧分支不可能被触达。

删除范围:三个旧分支的JSX(对应import的LoginView/CreateAccountView懒加载/
PasswordResetView懒加载)+ 三个组件源文件本身(login-view.jsx/create-account-view.jsx/
password-reset-view.jsx,删除引用后确认全仓库不含任何其它referrer)+ 两个文件对应的
已生成umd构建产物(webpack不会自动清理不再产出的旧chunk文件,这两个是被删除源文件
遗留下来的孤儿产物)。'cred'分支(ValidationView,不在chapterOneViews里,仍在真实使用)
未改动。defineMessages里对应的login/register/reset翻译key本次未删——它们现在也
无法被messages[view]这条动态查找命中,但涉及i18n提取产物的联动改动超出这次清理范围,
如实记录,未处理。

验证:npm run build:dev干净通过(三个旧chunk不再产出);真实浏览器完整走了一遍
登录(已有账号)/注册(全新手机号)/找回密码(重置刚注册账号的密码,重置后用新密码
重新登录成功)三条流程,均无回归。
2026-09-06 17:31:20 +08:00
Your Name ac8f0cf315 补上好友申请前后关系状态聊天页的入口,并把验证留言渲染成真实消息气泡
第3章要求pending_inbound/pending_outbound/none/blocked几种关系状态都能在真实聊天
窗口里看到(参考图 个人对话框(对方的状态)/(我的状态).png、开启沟通页面.png)。
messages-view.jsx里对应的横幅逻辑("以上是打招呼的消息"/"好友申请正在等待处理"等)
本身早就是对的,但排查发现两个问题:

1. 全项目没有任何入口能导航进这个视图——"新朋友"列表(FriendRequests)每一行没有
   onClick;好友个人主页(Profile)非好友分支只显示一段cannotAddReason纯文字,没有
   任何可点击进聊天的按钮。发起方和接收方在成为好友之前都完全看不到这个视图。
2. 验证留言原文(发好友申请时填的那段话,存在im_friend_requests表的message字段,
   不是真实Tinode消息,没有走topic.publish())没有被当作消息渲染出来,参考图里
   明确是作为聊天页第一条消息气泡展示的。

修复:
- contact-management-view.jsx: FriendRequests每一行加onClick,用(onCreateTopic||
  onTopicSelected)跳进对应聊天(同一套Profile.openChat已经验证过的冷启动安全写法,
  接受/拒绝按钮补stopPropagation不受影响);Profile非好友分支(blocked_by_peer/
  blocked_by_me/pending_inbound/pending_outbound)补一个"查看聊天"按钮,跟
  cannotAddReason文字并排展示(不复用people-profile-actions的两列grid,那是给
  发消息+音视频通话两个平级按钮设计的)。ContactManagementView/sidepanel-view.jsx
  早前就已经把onCreateTopic透传到位,这次只是多接一层。
- messages-view.jsx: loadRelationshipContext新增loadPendingRequestMessage,
  relationship是pending_inbound/pending_outbound时,用relationshipContext自带的
  request_id去GET /friend-requests换真实留言文本(没有留言用参考图默认文案"请求
  添加你为好友"兜底),渲染成一条合成的消息气泡插在消息列表最前面(不进
  messageNodes、不调用发消息接口)——pending_inbound左对齐、pending_outbound右
  对齐,气泡样式直接复用chat-message.jsx真实消息已经在用的.bubble/.left/.right
  这套CSS(app里"发出的"气泡实际是这套已实现的浅色而非参考图的蓝色,保持跟同一
  会话里其它真实消息一致,不単独改这一条的颜色去凑旧版参考图)。

验证:真实两个新账号(vcm909641发起→hankqa14b接收)走了一遍完整流程——
从"新朋友"列表点击行正确进入聊天页,留言气泡+"以上是打招呼的消息"横幅正确显示;
从Profile页"查看聊天"进入,留言气泡右对齐+"好友申请正在等待处理"横幅正确显示;
点"通过验证"接受申请后正确留在列表页不误触发导航;接受后重新打开同一会话,
合成气泡正确消失,被真实的"你们已成为好友,现在可以开始聊天了。"系统消息取代,
无残留/无重复。
2026-09-06 17:24:36 +08:00
Your Name e4b0868036 重新构建dev产物(本轮7个并行agent全部完成后的统一构建) 2026-09-06 16:44:01 +08:00
Your Name 3790472b4c 修复我的收藏/查找聊天记录加载失败时卡死在"加载中…",没有任何错误提示或重试入口
根因:Favorites 和 History(content-library-view.jsx)两个组件的 load() 里,请求失败的
.catch() 只调用 onToast() 弹一条 2 秒自动消失的提示,从未把 items 从初始值 null 变成别的
状态;页面渲染逻辑是"items === null 就显示'加载中…'",两者叠加导致一次真实失败之后页面永远
停在"加载中…",等用户反应过来去看的时候提示早就消失了,分不清"还在加载"和"永远不会加载完"。
这个坑本文件自己的 DownloadPage、group-management-view.jsx 的 GroupFiles、
contact-management-view.jsx 的 Profile 三处早就各自修过(都有一个独立的 loadError 状态),
唯独 Favorites/History 这两个用得最多的列表页当时漏掉了。

真实触发场景:用两个全新账号互加好友、发送图片/视频/文件/语音/文本等真实消息并收藏后,
发现收藏一条较大的(out-of-band)文件附件之后,GET /favorites?type=all 从此稳定返回
500 INTERNAL_ERROR(已确认是纯后端问题,不在前端能修的范围内,另行记录)——但这个后端
问题一暴露出来,"我的收藏"默认落地的"全部"tab 就会用真实数据 100% 复现这里描述的前端缺口:
打开就卡在"加载中…",没有任何办法知道发生了什么、也无法重试。

修复:Favorites/History 都新增 loadError 状态,非 append(首次/切换 tab、类型、搜索词)请求
失败时置为 true、成功时置回 false,切换 type/topic/view/query 时重置;渲染时 items === null
且 loadError 为真则显示"加载失败,请重试"而不是死等的"加载中…"。"加载更多"(append)请求
失败保持原样只弹 toast,不影响已经显示出来的列表。

验证:复现上述 500 场景(GET /favorites?type=all 持续 500),修复前"我的收藏"全部 tab
永久停在"加载中…";重新构建后同一账号同一坏数据状态下正确显示"加载失败,请重试"。
2026-09-06 16:39:13 +08:00
Your Name 0d2637861d 修复好友个人主页"发消息/音视频通话"对零消息历史的新好友必现"消息已禁用"
根因:Profile.openChat() 一直只调用 onTopicSelected(tinode-web.jsx 的 handleTopicSelected),
这个函数只负责"切换到某个已知 topic",从不发送原生 Tinode {sub} 请求。对一个从未被任何一方
订阅过的全新 P2P 会话(刚通过好友验证、双方都还没发过消息),本地没有缓存的 topic 订阅信息,
messages-view.jsx 的 isWriter 因此永远停在初始值 false,输入框被替换成"消息已禁用"提示且没有
任何入口能发出第一条消息——对每一个"刚加好友、还没聊过天"的真实场景都 100% 必现,不是边缘情况。

tinode-web.jsx 里其实已经有正确处理这个情况的 handleStartTopicRequest(通过 onCreateTopic prop
暴露):判断 topic 是否已缓存,没有缓存就先设置 sub.mode 为完整权限再切换,已缓存则直接退化成
普通的 handleTopicSelected,两种情况都能正确处理。问题是 sidepanel-view.jsx 渲染
ContactManagementView(好友个人主页/群成员资料页所在文件)时,只透传了 onTopicSelected,
从未把 onCreateTopic 一起传下去,导致 Profile 组件即使想调用正确的入口也拿不到。

修复:sidepanel-view.jsx 补上 onCreateTopic 的透传;contact-management-view.jsx 的
ContactManagementView/Profile 都新增接收这个 prop,openChat 优先调用 onCreateTopic
(不可用时兜底回退到原来的 onTopicSelected,避免类型不匹配报错)。

验证:用两个全新注册、互加好友但零消息历史的真实账号(ch7test1/ch7test2)在浏览器里复现——
修复前点"发消息"进入会话后输入框区域固定显示"消息已禁号"且没有任何输入控件,WS 日志确认
自始至终没有对该 p2p topic 发过 {sub};修复后点"发消息"能看到真实的 {sub} 请求(带
acs.mode: JRWPA)、输入框正常出现,并成功发送/收到多条真实文本消息。
2026-09-06 16:38:46 +08:00
Your NameandClaude Sonnet 5 4773ee5f64 修复"解散群组"仍用原生 window.confirm(),是本文件唯一未统一的危险操作确认弹层
现象:本工程已经在"退出当前登录"那次修复(46a98782)里明确认定原生 window.confirm()
和其余确认弹层不一致、且在部分内嵌 WebView/kiosk 环境下会被直接禁用(同步对话框调用
无提示地恒返回 false,操作被静默拦下且用户毫无感知)。全仓库复查后发现"解散群组"是
group-management-view.jsx 里唯一还在用这个模式的危险操作——同样不可逆、后果同样重大,
却没有跟上那次统一。

修复:改成两段式状态(先点"解散群组"进入确认态,再点确认态里的"确定"才真正调用
delTopic),复用本文件 OwnerSelect 早就在用的同款弹层视觉语言
(.group-sheet-backdrop/.group-confirm-sheet),不再依赖浏览器原生对话框。

验证:真实浏览器操作,用真实账号在真实群里走了"点解散→点取消"(弹层消失、群未受
影响、群列表可正常继续访问)和"点解散→点确定"(原生 WS {del hard:true} 成功执行,
200 ok,正确跳转回群列表且群已从列表消失)两条路径。

顺带记录:content-library-view.jsx 的收藏批量删除也还在用裸 window.confirm(),
是同一类问题,但属于第七章范围,本轮不动,留给对应章节的测试处理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:30:06 +08:00
Your NameandClaude Sonnet 5 30e6d5e300 修复风控弹窗取消后再次发送同一条回复,引用内容与正文被永久追加重复
现象:对一条历史消息点"回复",输入含链接的文本触发风控弹窗后点"取消发送"
(预览条正确保留,这是既有修复的正常行为),不改动文本再次点发送、这次
在弹窗里点"我已知晓,继续发送"——最终发出的消息里,正文被写了两遍(引用
块本身只有一份),例如输入"排查根因4 xxx",实际发出"[引用] 排查根因4
xxx排查根因4 xxx"。第三次发送同一条文本会变成正文三遍,以此类推。

根因:sendMessage()给回复拼接引用内容时写的是
`Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg)`。
Drafty.sanitizeEntities和Drafty.append(umd/tinode.dev.js,第三方SDK,
未改动)都会原地修改第一个参数并把同一个引用返回,从不做深拷贝——
sanitizeEntities内部直接改content.ent[i]再return content,append直接
first.txt+=、first.fmt.push()再return first。这里把this.state.reply.content
(React state里"正在回复"预览条依赖的同一个对象)原样传进去,等于每次
点发送都会把当次输入的正文永久焊死在这条回复引用的原始内容上。

平时这个副作用不可见:一次成功发送后handleCancelReply()马上清掉
state.reply,被污染的对象跟着一起扔掉。但URL风控检查会把真正调用
doSend()(进而调用这行代码)的时机推迟到弹窗resolve之后,"取消发送"
按现有修复(a964d51b)刻意不清空state.reply以保留预览条——这就让
被污染的引用内容留了下来,等下一次点发送时,同一个已经被污染的对象
再被追加一次,正文因此重复。

修复:拼接前对this.state.reply.content做一次深拷贝,让两个SDK方法的
原地修改只作用于用完即扔的副本,不管sendMessage对同一条还没清空的
回复调用多少次都不影响原始状态。

验证:真实浏览器操作,对消息点回复,输入带链接文本发送触发风控弹窗,
点"取消发送"(预览条与文本保留正常),不改文本再次发送并这次确认
"继续发送"——发出的消息引用块和正文都只出现一次,不再重复。

第14+轮最小粒度测试 Ch4 发现(回复+风控弹窗联动场景,紧邻已知修复
a964d51b验证时发现)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:24:30 +08:00
Your NameandClaude Sonnet 5 53e28e3bc6 修复"我在本群的昵称"保存时硬编码 version:0,与本文件其余乐观锁写法不一致
现象:GET /groups/{topic}/members/me/profile 实测确认真实带 version 字段(非空),
但 MyNicknameEditor 保存时一直硬编码 updateMyGroupProfile(topic, value, 0),从不使用
这个已经到手的真实版本号。

根因/影响:跟本文件其余"编辑单个字段+乐观锁"写法(GroupNameEditor/
GroupDescriptionEditor 用 getGroup 拿到的真实 version;Governance/OwnerSelect 的
注释更明确写过"version<=0 时服务端会整个跳过乐观锁 WHERE 子句,两次几乎同时的写入会
静默覆盖,没有冲突信号")不一致。实测验证这里同样如此:真实 version 是 1,PUT 时传 0
依然 200 成功,服务端自己把 version 推到了 2——多标签页/多端几乎同时修改自己在本群的
昵称时,后写入的会静默覆盖先写入的。

修复:GET 到的 version 存进 state,保存时原样带上,不再硬编码 0。

验证:真实浏览器操作,改群昵称两次,第二次 PUT 请求前 GET 到的 version 正确是上一次
保存后服务端返回的版本号(1→2→3 依次递增),不再是硬编码的 0。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:17:03 +08:00
Your NameandClaude Sonnet 5 8cb96e9254 修复撤回消息在对方界面显示"无效内容"而非"消息已删除"
现象:真实调用撤回接口成功后(recall_window_seconds=120秒窗口内,长按菜单
"撤回"),撤回发起方自己的消息列表里这条消息直接消失,没有问题;但对方
(消息接收方)的聊天界面上,这条消息位置渲染成一个灰色警告图标+"无效内容"
(invalid content),不是预期的"此消息已被撤回/删除"占位提示。

根因:Tinode原生协议里,一条消息被撤回后,服务端把这条消息的content原地
置为null(seq位置保留,其余内容清空)。chat-message.jsx渲染消息内容时按
Drafty富文本、纯字符串、其余情况三段分支处理,content为null时两段判断
(Drafty.isValid和typeof=='string')都不成立,直接落到兜底的"invalid
content"分支——这段代码从未加过null-content(撤回)的专门判断。这跟
messages-view.jsx里另一套依赖msg.hi标记位的"删除"消息渲染(走独立的
<MetaMessage deletedText=.../>组件)是两码事:撤回消息还是走的普通
ChatMessage组件,从未接入deletedText这个机制。

修复:chat-message.jsx新增一个content==null的分支,复用
messages-view.jsx里messages.message_deleted已经注册好的同一个i18n
id/文案("消息已删除",非新增翻译串),渲染为灰色图标+"消息已删除",
不再落到"无效内容"兜底分支。

验证:真实浏览器操作,两个全新注册账号互为好友,A发一条文本消息后
立即长按撤回(120秒窗口内),B的聊天界面上这条消息正确显示"消息已删除"
(此前是"无效内容")。

第14+轮最小粒度测试 Ch4 发现(撤回消息渲染场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:12:38 +08:00
Your NameandClaude Sonnet 5 b200dc2525 修复长按消息菜单"撤回"功能完全失效:LOCAL_SEQID 经 webpack externals 解析为 undefined
现象:验证第2章长按消息弹出菜单的时间窗口门控时发现,无论消息是不是自己刚发的、群主/普通
成员、发送后多少秒,长按菜单里永远不出现"撤回"这一项——服务端 messagePolicy 明确返回
recall_window_seconds=120,但这个选项在任何情况下都不会出现。

根因:messages-view.jsx 原来 `import { ..., LOCAL_SEQID } from 'tinode-sdk'`,但
webpack.config.js 把 'tinode-sdk' 映射到全局变量 `tinode`(externals: {'tinode-sdk': 'tinode'}),
而这个全局对象(umd/tinode.dev.js 的 UMD 导出)只挂了 {AccessMode, Drafty, TheCard, Tinode}
四个键,从未包含 LOCAL_SEQID——尽管 tinode-sdk 的 ES 模块本身确实导出了这个常量
(umd/tinode.dev.js 内部仍以 `const LOCAL_SEQID = 0xFFFFFFF;` 定义并在多处话题方法里使用,只是
没有挂到对外暴露的全局对象上)。externals 映射对"这个键不在对象上"和"这个导出根本不存在"没有
区分,所以按名字导入被静默解析成 `undefined`,没有任何构建报错。撤回菜单项的门控条件
`params.seq > 0 && params.seq < LOCAL_SEQID` 因此变成 `seq < undefined`,在 JS 里恒为
false——不是时间窗口判断错了,是入口条件本身就从未为真过,撤回功能对所有用户、所有消息实质性
完全失效。

用控制台直接调用 MessagesView 实例的 handleShowMessageContextMenu 并打印各分支布尔值定位到
这一步:seq=2 这样明显合法的正整数,seqOk 却是 false,才追到 LOCAL_SEQID 本身是 undefined。

修复:messages-view.jsx 不再从 'tinode-sdk' 导入 LOCAL_SEQID,改为本文件内本地声明
`const LOCAL_SEQID = 0xFFFFFFF`,与 SDK 内部定义保持一致(未改动 webpack externals 映射本身——
其它页面都依赖它保持现状,改映射风险更大、影响面更广)。

验证:登录测试账号,在群聊里发一条新消息立即长按,确认"撤回"正确出现在菜单中;点击后
`POST /topics/{topic}/messages/{seq}/recall` 返回 200,消息从会话中正确消失;对比一条已经
超过 recall_window_seconds(同时也超过 edit_window_seconds)的旧消息,确认"撤回"和"编辑"
仍正确不显示,时间窗口判断本身未被破坏。

注:umd/index.dev.js 本次未随此提交提交——工作区里并行的其它 agent 仍在改
contact-management-view.jsx / sidepanel-view.jsx 且未提交,重新构建的 index.dev.js 会把它们的
未提交改动一起编译进同一产物文件,为避免混入不相关改动,只提交 messages-view.jsx 按路由懒加载
的独立 chunk(umd/src_views_messages-view_jsx.dev.js 及其 .map),不受这个问题影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:05:19 +08:00
Your NameandClaude Sonnet 5 5c688007c8 修复会话列表把系统通知/合并转发消息的预览行恒显示成无信息量的"[消息]"
现象:好友通过验证、群成员加入、群邀请卡片("邀请你加入群聊。")这类系统消息
(x-im-type=system_notice/system_action)和合并转发消息,点开会话看到的是完整的一句
人话,但会话列表的预览行不显示这句话,一律显示占位符"[消息]"。

根因:message-center-view.jsx 的 draftyPreview() 只认三种取值来源——纯字符串、
Drafty 附件实体(content.ent[],映射成"[图片]"等标签)、以及 content.txt——最后兜底
到"[消息]"。但 system_notice/system_action 的真实文案字段是 content.text(跟
chat-message.jsx 渲染这类消息时读的字段一致),合并转发消息的文案字段是
content.comment/content.title,三者都不是 content.txt,全部落进兜底分支。

修复:draftyPreview() 补上对 content.text(system_*)和 content.comment/
content.title(forward_bundle)的识别,命中就直接用这句真实文案,仍然兜底到
"[消息]"覆盖真正未知的内容形状。

验证:真实浏览器操作,两个全新注册账号之间邀请入群后,会话列表预览行正确显示
"邀请你加入群聊。",不再是"[消息]"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:58:45 +08:00
Your NameandClaude Sonnet 5 64021fe428 补上"邀请好友入群"功能,POST /groups/{topic}/invitations 真实可用但前端零调用点
现象:docs/images/内容沟通对话3-开启进群验证与拉人状态.png 里"你邀请时间旅行者、
李惠惠加入群聊"这条系统事件证明产品设计里确实有"从我的好友里直接选人邀请进群"这条
路径;im-api.js 的 inviteGroupMembers()(POST /groups/{topic}/invitations)也早就
封装好,服务端接口本身可用(实测确认:请求成功返回 pending 状态的邀请记录,对方会在
跟我的会话里收到"邀请你加入群聊。立即加入"卡片)。但全仓库检索这个函数零调用点——
群聊设置页成员格的"添加"格子原来点了只弹一句"请通过转发/分享群二维码邀请成员",把
这条真实存在的路径堵成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,跟
"邀请"语义不同,不能互相替代)。

修复:新增 InviteFriends 组件(复用 MemberPicker 同款字母分组多选布局),数据源换成
好友列表并按当前群成员名单过滤掉已在群里的人,选中后调用 inviteGroupMembers;
GroupSettings 成员格的"添加"改为跳转到这个新页面。

顺带修复一处会导致这个新路由完全打不开的既有陷阵:tinode-web.jsx 的 SIDE_PANEL_VIEWS
是一份独立于 group-management-view.jsx 自己 GROUP_MANAGEMENT_VIEWS 的硬编码合法视图
名单(文件里 104-107 行注释解释过这就是历史上"新增路由忘记同步这份名单导致深链被清空"
的同一类坑)——只在后者加了新路由名、没有同步这份名单,会导致 handleHashRoute 判定
"Unknown sidepanel view" 直接不渲染侧栏,新页面点了跟没点一样,一并补上。

验证:真实浏览器操作,两个全新注册账号(好友关系),从群设置页"添加"格子进入,勾选
好友、点完成,对方在跟我的会话里收到真实的"立即加入"邀请卡片,群设置页成员数不变
(邀请只是待确认,尚未入群,符合预期)。邀请确认接受这一步会 500,是已知后端问题
(新增72,AcceptInvitation 里一处表名/字段名写错,见 im_be/server/db/mysql/
tenant_groups.go:448 用了不存在的 subscriptions/Topic/User 而不是 im_subscriptions/
topic/userid),不是本次改动引入。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:58:24 +08:00
Your NameandClaude Sonnet 5 b6adc032a2 修复发送含链接消息时切换话题,风控弹窗可能把消息送错会话
现象:在会话A输入含链接的消息并点发送,POST /security/urls/check 请求还未返回时切换到
会话B,请求返回后风控弹窗会在会话B里弹出(本该属于会话A),此时点"继续发送",消息被
真实发进了会话B,而不是最初输入它的会话A——跨会话消息误投递。

复现:会话A(kcg051307)输入"https://example.com/racefix?token=fix002"点发送,立即
(在checkURL网络请求返回前)切换到会话B(r12c56b2),风控弹窗在会话B视图下弹出;点
"继续发送"后消息被投递进会话B,会话A完全没有收到,会话B收到了本不该发给它的内容
(本次因B恰好还不是好友触发"发送失败"提示,但发送动作本身已经指向了错误的会话,
如果B是好友会真实送达)。

根因:`getDerivedStateFromProps`在话题切换时会把`riskDialog`重置为null,但这只能防住
"弹窗已经弹出、随后切换话题"这一种时序——它防不住"checkURL还在进行中就切换话题,
此时机的.then()回调在切换之后才执行"这种反过来的时序:这次setState({riskDialog:{...}})
是在切换完成之后才发生的,不会被这次重置覆盖,于是在当前(错误的)话题下重新弹出弹窗,
且弹窗里的`doSend`闭包沿用的是`this.props.sendMessage`,按确认时的当前话题发送,
不是消息最初输入时所在的话题。

修法:sendMessage()进入URL风险检测分支时已经缓存了`topicForDraft`(用于清草稿),
在checkURL的.then()回调最开头加一道判断——如果`this.state.topic`已经不等于
`topicForDraft`,说明用户已经离开发起这次检测的会话,直接返回false(视同消息被放弃,
不弹窗、不发送),不再继续读取风控结果或setState。这与本文件已有的"两条风险消息连续
触发、后一个覆盖前一个"场景处理逻辑(resolve(false)后覆盖)是同一种"宁可丢弃، 不可
错误投递"的一贯取舍,只是这次要防的是切换会话而不是重复触发。

验证:npm run build:dev后浏览器实测——(1) 会话A输入风险链接点发送,立即切换到会话B,
2秒后确认会话B里没有弹出风控弹窗(修复前必现);(2) 切回会话A确认最后一条消息仍是修复
前测试遗留的旧消息,本次新消息没有出现在A也没有出现在B,两个会话都没有收到,符合
"宁可丢弃不可错投"的设计;(3) 对照组(不切换话题、正常触发风控)依旧能看到弹窗并正确
发送/取消,未被这次改动影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:57:22 +08:00
Your Name 644b663ed1 重新构建本轮2处修复的编译产物(通话小窗预览显隐 + 语音通话菜单翻译)
对应前两个commit的源码改动:src/widgets/call-panel.jsx、
src/i18n/zh.json、src/i18n/zh-TW.json。跑 npm run build:i18n 重新生成
src/i18n.min/{zh,zh-TW}.json,再跑 npm run build:dev 让 webpack 重新
编译对应的 umd 产物。

本次只挑了跟这两处修复相关的产物文件加入本commit:call-panel.jsx 自己
的 chunk(含 sourcemap)、zh/zh-TW 两个 i18n chunk。umd/index.dev.js、
umd/index.dev.js.map,以及 umd/src_views_messages-view_jsx.dev.js 等其它
umd 产物这次构建也发生了变化,但那是因为这台机器上同时有其他并行任务在
改 src/views/messages-view.jsx、tinode-web.jsx、contact-management-view.jsx
等跟本轮通话章节测试无关的文件(构建时是从磁盘当前状态整体编译的,会把
它们尚未提交的改动一起编译进去)——这些不是我这轮的改动,没有一并提交,
保留在工作区未纳入版本控制,避免把别的任务未完成的改动夹带进这个commit。
2026-09-06 15:56:44 +08:00
Your Name 56cd91d40e 修复语音通话菜单项中文翻译误译成"称呼",实际含义是发起语音电话
会话更多菜单里发起语音通话的入口(tinode-web.jsx menu_item_audio_call,
唯一消费点,2236 行 formatMessage 渲染其 title)中文翻译一直是"称呼",
和视频通话入口"视频电话"并列显示时,用户会把它当成设置备注昵称之类的
功能,点开才发现其实是拨打语音电话——源头是英文 defaultMessage 只有一个
词"Call",同时可以理解成"称呼/命名"或"打电话",翻译流程选错了词义,
zh.json/zh-TW.json(简繁中文)都错,全仓库搜索确认"称呼"/"稱呼"没有
被其它任何 key 使用,不是复用了别的合法翻译。

改成"语音电话"/"語音電話",与同一菜单里视频通话入口现有的"视频电话"
措辞保持一致(未改动"电话"这个词本身的选择,只修正词义选错的问题,
避免额外引入新的用词不一致)。

验证:修复前 tw.props.intl.formatMessage({id:'menu_item_audio_call'})
返回"称呼";重新跑 npm run build:i18n 生成 src/i18n.min 后同一调用
返回"语音电话"。
2026-09-06 15:56:12 +08:00
Your Name 9eb9df649d 修复视频通话振铃阶段本地小窗预览提前可见且可拖动
本地小窗预览(.call-party.self)此前只由 audioOnly/minimized 控制显隐,
没有校验连接是否真正建立(this.state.remoteStream),导致视频通话振铃
阶段(对方尚未接听、只有本地摄像头画面)小窗就已经以 display:block 可见,
且 dragStart 里对应的分支同样没有这层校验,振铃阶段就能拖动这个本应还
不存在的预览框。

render() 里 .call-party.self 的 disabled 属性补上 !connected 判断,
dragStart 里进入 'self' 拖动分支的条件补上 this.state.remoteStream 校验,
两处保持一致:只有远端媒体真正到达(连接建立)后小窗才出现并可拖动。

验证:用 canvas.captureStream()/AudioContext 构造的合法 MediaStreamTrack
配合 monkey-patch getUserMedia,绕开已知后端通话邀请 400 缺陷直接驱动
CallPanel 组件状态。振铃阶段(waitingForPeer=true, remoteStream 未设置)
下 .call-party.self 计算样式 display:none、无 [disabled] 属性对应的
cursor:grab;调用 handleTrackEvent 模拟远端媒体到达后,display:block、
cursor:grab,且用真实鼠标拖拽(非合成事件)验证了拖动/边界收紧都正常。
2026-09-06 15:55:48 +08:00
Your NameandClaude Sonnet 5 dbf47847fe 修复系统消息列表漏查表的"group.join_request.accepted/rejected"机器码
现象:核对第2章系统消息列表最近修复的"group.announcement.updated"查表遗漏问题时,逐个比对
im_be 的 insertGroupEventTx 调用点,发现入群申请审批这一条并不在当初核对的 9 种静态事件码
之列——群管理员在群管理页批准或拒绝入群申请后,系统消息列表会展示原始机器码,而不是可读文案。

根因:TenantGroupJoinRequestResolve(server/db/mysql/tenant_groups.go:642)拼的事件码是
"group.join_request."+status(status 取 server/store/types/groups.go 的
GroupJoinRequestStatusAccepted/Rejected,即 "accepted"/"rejected"),实际产生的是
"group.join_request.accepted"/"group.join_request.rejected" 两个完整机器码。这两个和已收录的
"group.join_request.created"(提交申请时那条)是三个不同的 key,NOTIFICATION_TEXT 表里只有
后者,前两个查表落空,systemNotificationText() 兜底把原始机器码直接展示给用户。

修复:在 message-center-view.jsx 的 NOTIFICATION_TEXT 表里补上这两个 key。

验证:读 im_be 源码逐个核对 insertGroupEventTx 全部调用点,确认这是唯一还缺失的动态拼接事件码;
重新构建后确认产物里两个 key 及对应文案都已生效。未在真实环境触发一条这样的通知做端到端验证——
需要另一账号先申请加群、本账号再批准/拒绝,属于第6章群管理操作范畴,本轮未展开搭建。

注:umd/index.dev.js 本次同样未随此提交提交——工作区里此刻并行的其它 agent 正在改
i18n/zh(-TW).json、group-management-view.jsx、sidepanel-view.jsx、call-panel.jsx、
contact-management-view.jsx 等多个文件且均未提交,重新构建的 index.dev.js 会把它们的未提交
改动一起编译进同一产物文件,为避免混入不相关改动,本次只提交源码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:51:10 +08:00
Your NameandClaude Sonnet 5 5ddace97f4 修复好友资料/验证页对"当前关系"的两处判断遗漏,与已有的好友申请状态机修复形成完整闭环
现象一:Profile 组件(好友/陌生人资料页)在 getConversationContext 回来后只合并了
relationship 字段,没有一并合并 capabilities——这个组件判断"能否显示添加好友按钮"
用的是 profile.capabilities.can_add_friend,不是 relationship 本身。已实测复现:
刚发送好友申请、relationship 已经变成 pending_outbound 时,若走的是"直接刷新这个
资料页"而不是 Verification 组件自己的发送成功回调(该回调已由另一处提交单独修复),
canAdd 依然读到发送前搜索结果种子里的 can_add_friend:true,"添加好友"按钮继续可点,
可再次触发一次好友申请(配合后端不去重的缺口——新增44)。

现象二:Verification 组件(好友验证/发送申请页)完全没有用 getConversationContext
校正过 relationship,只依赖 getUserProfile——但该接口对非好友直接 403,403 时的
catch 分支什么都不做。真实复现:本次会话内先成为好友、再解除好友关系后,若该好友的
profile-seed 缓存还留着"好友"时期的旧数据,重新打开验证页会一直显示"你们已经是
好友了",且不提供发送框,除非整页刷新清掉内存缓存,否则无法向这个人重新发起好友申请。

修复:两处都在 getConversationContext 成功回调里,把它返回的字段当作当前关系的权威
来源合并进本地 profile 状态——Profile 一并合并 capabilities(而不只是 relationship),
Verification 补上原本没有的 getConversationContext 调用去合并 relationship。

验证:真实浏览器操作,两个全新注册账号之间反复走查——(1) 发送好友申请后直接刷新
资料页,"添加好友"按钮正确消失、显示"好友申请正在等待验证";(2) 成为好友后互相
删除好友关系,立刻重新打开验证页,正确显示可发送的验证留言表单,不再卡在"你们已经
是好友了"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:41:36 +08:00
Your NameandClaude Sonnet 5 605cd12a6e 修复第1章各认证页之间切换会带出上一个页面的报错文案
现象:在注册页填错邀请码提交后(正确显示"邀请码错误,请重新输入"),点击"已有账号?登录"
切到登录页,登录表单顶部会原样带出这条"邀请码错误"提示——用户在登录页什么都还没做,却先看到
一句跟登录毫不相关的报错。反过来,登录密码错误后点"马上注册"也会把"账号或密码错误"带到刚打开
的注册页。

根因:errorText/errorLevel 是 tinode-web.jsx 顶层的一份全局共享状态,不跟当前显示哪个
sidepanel 绑定。Login 组件自己的"切换企业"链接会在跳转前先调用一次 onError() 清空,说明
之前已经意识到这个状态会带着走,但企业码/登录/注册/忘记密码等页面之间的其它跳转都没有做
同样的处理——其中 Register 等好几个视图目前甚至没有拿到 onError 这个 prop,需要在各处
分别补,比较分散。

修复:在 AuthOnboardingView(chapterOneViews 统一走的入口组件)里加一个以 view 为依赖的
useEffect,view 变化时统一调用 onError() 清空全局报错状态,一次性覆盖企业码/登录/注册/
忘记密码等视图之间的所有切换路径(含浏览器前进后退),不用逐个跳转调用点分别处理。

验证:注册页提交错误邀请码复现"邀请码错误,请重新输入"后点击"登录",确认登录页不再带出
该提示;反向验证登录页密码错误后点击"马上注册",确认注册页同样不再带出"账号或密码错误"。

注:umd/index.dev.js 本轮未随此提交一起提交——auth-onboarding-view.jsx 不是按路由懒加载的
独立 chunk,此刻工作区里另有并行 agent 正在改 contact-management-view.jsx / call-panel.jsx
且未提交,重新构建的 index.dev.js 会把它们尚未提交的改动一起编译进同一个产物文件,为避免把
不相关改动混进这次提交,本次只提交源码,构建产物留给后续统一重新构建的提交处理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:40:43 +08:00
Your NameandClaude Sonnet 5 a71aee9487 修复"安全"设置页退出登录无二次确认,单击立即登出
现象:从 account-settings-view.jsx"账户设置"、info-view.jsx,以及第2章系统消息列表"前往
安全设置"跳转按钮都能到达 acc-security-view.jsx 这个"安全"页(并非废弃页面,是与
personal-settings-view.jsx"设置"页并存的独立入口),点击"登出"没有任何二次确认,单击
图标就会立即退出登录,且没有任何撤销机会。

根因:该文件里"登出"按钮的 onClick 直接调用 this.props.onLogout(),而同文件里紧挨着的
"删除帐户"按钮走的是 onShowAlert 弹确认框(handleDeleteAccount)。此前 46a98782 那一轮
已经把 personal-settings-view.jsx"设置"页的登出从原生 window.confirm() 改成了自己的
ConfirmSheet/LogoutConfirm,但漏改了这个仍然可达的旧版"安全"页入口。

修复:新增 handleLogoutRequest,复用本文件已有的 onShowAlert 机制弹确认框,文案
("退出登录"/"确定要退出当前登录吗?")与 personal-settings-view.jsx 的 LogoutConfirm
保持一致,不新造一套措辞。

验证:本地登录后访问 #security,点击"登出"确认弹出对话框(标题/正文/按钮文案核对无误);
点击"取消"保持登录态且仍停留在安全页;点击"好"确认后正确清除 auth-token 并退回登录/租户
判断逻辑接管的空 hash。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:36:55 +08:00
Your NameandClaude Sonnet 5 51e0fc420f 修复发送好友申请后个人资料页仍显示可点击的"添加好友"按钮
现象:搜索陌生人->进入资料页->点"添加好友"->发送验证留言成功后,
无论是验证页800ms后的自动跳转、还是手动点返回,回到这个人的资料页时
仍然显示"添加好友"主按钮(本应显示"好友申请正在等待验证"提示文案),
且这个按钮此时仍可点击,会打开验证页再次发起一次申请(配合后端
好友申请不去重的缺口——新增44——实际造成同一个人收到多条重复申请)。

根因:发送成功后 Verification 组件把 profile-seed 缓存的 relationship
patch 成了 pending_outbound,但没有同步更新 capabilities.can_add_friend
——Profile 组件判断是否展示"添加好友"按钮的 canAdd 变量读的是
profile.capabilities.can_add_friend,不是 relationship 本身,遗留的
can_add_friend:true(原样来自发送前的搜索结果种子缓存)导致按钮继续显示。

修复:发送成功后 patch 缓存时把 capabilities.can_add_friend 一并设为
false,和 relationship 保持同步。

验证:真实浏览器操作,两个全新注册账号之间,搜索->进入资料页->添加好友
->发送验证留言,自动跳转回资料页后正确显示"好友申请正在等待验证",
"添加好友"按钮消失,无法重复发起申请。

第14+轮最小粒度测试 Ch3-4 发现(好友申请气泡状态机场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:35:50 +08:00
Your NameandClaude Sonnet 5 552c891cc3 修复清除缓存显示"已释放约0.0MB",与实际清理量不符
现象:设置-清除缓存确认后,无论Cache Storage里实际有多少数据,展示的"已释放约
X MB"几乎总是0.0,即使清理动作本身(caches.delete)确实生效(caches.keys()清理后
变为空数组)。

根因:原实现在caches.delete()前后各调用一次navigator.storage.estimate(),用两次
usage之差算释放量。实测确认Chrome的storage.estimate()不会同步反映Cache Storage的
删除——caches.keys()已经确认清空后,estimate()仍继续报告删除前的usageDetails.caches
数值,实测滞后超过500ms未刷新。人为塞入一个2MB的Cache Storage测试项复现:删除成功
但"已释放"显示0.0MB;塞入3MB项验证修复后正确显示3.0MB。

修法:不再依赖storage.estimate()的前后差值,改成删除前逐个遍历所有Cache
Storage条目、读取每个response的真实blob大小并累加,直接得到本次会话即将删除的
真实字节数,不受浏览器存储统计滞后影响。

验证:npm run build:dev后浏览器实测——(1) 无缓存数据时点击"清除缓存",正确显示
0.0MB且弹层能正常从"清空缓存"态切到"缓存已清理"态、点"完成"可关闭;(2) 用JS人为写入
2MB/3MB测试用Cache Storage条目,分别清理后弹层准确显示2.0MB/3.0MB,且caches.keys()
确认条目已被真实删除。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:30:40 +08:00
Your Name cd092108c2 重新构建dev产物(清理调试残留日志+本轮3处修复的编译产物) 2026-09-06 15:04:00 +08:00
Your Name 4715ea4497 修复未登录状态下深链/跳转到某个话题导致应用整体白屏
handleHashRoute 判断 path[1](话题名)是否合法只检查了 Tinode.topicType(topicName)
这一个语法层面的条件——没登录时把hash设成一个真实群/好友的"#/<topic>"地址(比如
退出登录后浏览器历史回退、或者一个带话题参数的通知/深链在未登录状态下被打开),
话题名本身语法上合法,于是mobilePanel被设成'topic-view'。但render()里
MessagesView真正渲染的前提除了mobilePanel还要求!chapterOneActive(即
this.tinode.isAuthenticated())——这个前提没登录时是假的。结果移动端下
SidepanelView的显示条件(mobilePanel=='sidepanel')和MessagesView的显示条件同时
为假,两边都不渲染,应用整体白屏(app-container是空的,没有任何报错),
仅靠换hash也无法恢复,只能整页刷新。

修复:判断话题合法性时把"当前是否已认证"也纳入,未登录时统一走已经验证过安全的
"话题名非法"分支(topicName置null,mobilePanel回退到sidepanel)。

已实测复现:退出登录后把location.hash设成一个真实群的话题地址,修复前白屏,
修复后正确回退。
2026-09-06 15:03:48 +08:00
Your Name ed9a92896c 修复系统消息列表原样显示机器码"group.announcement.updated"
NOTIFICATION_TEXT 查表漏收了这一种群事件码。real账号(asn494180)的真实系统消息
列表里实测出现过未翻译的原始事件码——server/db/mysql/tenant_groups.go 的
insertGroupEventTx 一共会写9种 group.* 事件(announcement.updated/created、
join_request.created、member.joined/left/updated、mute.updated、
owner.transferred、role.updated、settings.updated),这张表原来漏了公告更新
这一种,是9种里唯一没查表命中、直接把机器码露给用户看的。
2026-09-06 15:03:34 +08:00
Your Name a81a7b162d 修复群系统事件消息重新变回普通气泡+"未找到"作者名(并发编辑意外丢失CSS)
e6e51f62 提交的".meta .bubble.system"样式规则,被同一时间并发运行的另一个agent
提交(8087fd7e)在给css/base.css暂存自己的"通话小窗grab光标"改动时意外整个删掉了
(这个环境多个并发session同时改同一个文件、共享git暂存区,8087fd7e自己的commit信息
也提到了这个现象,但它删掉这条规则时没有同时补上——本轮测试期间反复出现的同一类问题)。

净效果:meta-message.jsx渲染系统事件消息用的".bubble.system"类此前已经没有对应CSS,
群里的成员加入/角色变更/公告发布这类系统提示消息会退回默认气泡样式,重新出现
"未找到"这种内部占位作者名——e6e51f62本来要修的问题又变相复现了。

重新补上这条规则,内容与e6e51f62最初提交的完全一致。
2026-09-06 15:03:25 +08:00
Your Name fa2288c497 修复群文件上传不传file_type,图片/视频/语音群文件全部显示成通用文件图标
根因:POST /groups/{topic}/files 的 file_type 是客户端登记时自己声明的字段(im-api.js的
addGroupFile早就留了这个参数),不是服务端拿mime_type反推的——实测:上传一张mime_type
正确记成"image/png"的图片,只要请求体不带file_type,服务端就把它落成字面量"file"(不是
留空)。GroupFiles的onFile上传流程一直没传这个字段,导致FILE_TYPE_META准备好的
image/video/audio/document四种彩色图标从未被真正用上,所有群文件不分类型清一色显示灰色
通用文件图标。

修复:新增fileTypeFromMime辅助函数(与content-library-view.jsx的同名函数用同一套mime前缀
优先、扩展名兜底文档类型的判断口径),上传时算出分类一起传给addGroupFile。

验证:真实浏览器上传一张PNG,接口返回file_type从"file"变成"image",列表图标从灰色通用
文件图标变成绿色图片图标;对照同一会话里upload-fix之前的旧文件,图标仍是灰色(存量数据
不受影响,符合预期,不需要也没有接口能批量回填旧记录)。
2026-09-06 13:47:19 +08:00
Your NameandClaude Sonnet 5 ddf23d40b2 修复新会话首次打开时消息历史被静默截断为1条、对方已读回执永不刷新
根因:tinode-web.jsx的prefetchLatestMessagePreviews()(会话列表"最后一条消息"预览)
会在会话列表一渲染就对每个有动态但本地无缓存摘要的话题调用
topic.subscribe(startMetaQuery().withLaterData(1)...),只为预览目的抓取"最新1条"
消息——这常常发生在用户真正点开这个会话之前。这次订阅会把tinode-sdk的
Topic._attached置为true,且把_maxSeq设成那一条消息的seq。

用户随后点开会话时,messages-view.jsx自己的subscribe()发现topic.isSubscribed()
已经是true就直接return,从未真正发出过完整的{sub}请求。即使去掉这个短路,
SDK的Topic.subscribe()本身在_attached为true时也会无条件短路成
Promise.resolve(this),完全不看传入的getParams——所以无论如何都不会把
withLaterSub()/withAux()等真正需要的查询发给服务端。

现场复现:全新注册的两个互为好友账号之间发4条真实消息,全新session下第一次点开
该会话,只渲染出最新1条,其余3条静默丢失;对方的已读回执(应变蓝色双勾)永远
不刷新——这两个现象都会在离开会话再重新打开后自愈(因为离开会清掉_attached),
但用户在此之前看到的是一个残缺、状态错误的会话。

修复分两处:
1. subscribe()内,若topic已经isSubscribed(),先topic.leave(false)(只清客户端和
   服务端的订阅态,不清本地已缓存消息),再执行真正的subscribe(),确保这次一定
   是货真价实的、服务端会认真处理getParams的订阅请求。
2. 把消息分页查询从withLaterData(MESSAGES_PAGE)("给我比本地_maxSeq更新的消息")
   改成withData(undefined, undefined, MESSAGES_PAGE)("给我最新的N条消息",不依赖
   本地缓存游标)。前者在_maxSeq被上述预览订阅污染后会查询"since:被污染的seq+1",
   服务端自然返回空;后者是绝对查询,对全新话题(_maxSeq为0,两者退化成同一个
   since:undefined查询)行为不变,对被污染的话题能正确要回完整历史。

第14+轮最小粒度测试 Ch3-4 发现(P2P单聊消息状态机场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:46:58 +08:00
Your Name 8087fd7e97 补上视频通话小窗拖动功能遗漏的CSS改动(.call-party.self 的 grab 光标提示)
上一个 commit(call-panel.jsx 里新增的"全屏连线中拖动本地小窗"功能,对应 8.9 双方
视频通话中.png 的"小窗应支持拖动"需求)只提交了 JSX/JS 那部分,配套的 CSS
(.call-party.self 增加 cursor:grab 提示可拖动,[disabled] 态改回 cursor:unset)
当时被同一文件里其他并发改动的暂存操作顶掉,没有一起进去——这个环境里多个并发
session 同时在改 css/base.css,共享 git index 导致 git add 经常把彼此的暂存内容
一起扫进对方的 commit,这是本轮测试期间反复确认到的现象,不是这次改动本身的问题。
JS 逻辑不受影响(拖动本身早就用真实指针事件验证过可用),这里只是补上缺的光标提示
样式,让用户在尝试拖动前就能看出这个小窗是可拖的。
2026-09-06 13:40:03 +08:00
Your NameandClaude Sonnet 5 e6e51f625e 群系统事件消息改为居中纯文本渲染,不再走普通气泡+作者名的展示逻辑
根因:群里的系统事件消息(成员加入、角色变更、公告发布等,x-im-type=system_action/
system_notice)服务端并不带真实的 `from`,但 messages-view.jsx 一直把它们和普通消息
一样交给 ChatMessage 渲染——chat-message.jsx 的 fullDisplay 逻辑会在气泡下方额外画一行
发送者名字,解析不到发送者时回退显示"未找到"。对照参考图 内容沟通对话3-开启进群验证与
拉人状态.png,这类系统事件应该是居中的纯灰字提示,不带头像、不带气泡、不带作者名——现状
既不符合参考图,也让"未找到"这种内部占位文案暴露给了用户。

怎么修:meta-message.jsx 新增 systemText 分支,复用日期分隔条同一套"居中提示"视觉语言
(.meta .bubble.system,无背景、纯居中灰字);messages-view.jsx 在消息渲染分支里识别
system_action/system_notice 且不带可操作按钮(group_invitation_created 那种带
"立即加入"按钮的除外,仍走完整气泡展示)的消息,改为渲染 MetaMessage 而不是 ChatMessage。

验证:用真实群(3 个真实账号)走了两遍"扫码申请入群→通过审批",会话里"有成员通过审批
加入了群聊。"正确显示为居中灰字,不再出现气泡、头像和"未找到"作者名;带按钮的
group_invitation_created 消息不受影响,仍是完整气泡+"立即加入"按钮。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:39:35 +08:00
Your Name e53af93409 修复收藏页多选模式下类型状态丢失、跨tab选择静默累加两个问题
根因1:'favorites-select' 是全部类型 tab 共用的同一个路由,本身不带类型信息,Favorites
组件原来进入多选模式时把 type 硬编码回退成 'file',不管进入前实际停在哪个 tab。实测在
"文本"tab 点"多选",列表和头部计数会静默换成"文件"tab 的数据,用户看不出发生了什么,
勾选/删除会作用在自己根本没看到的文件项上——测试过程中因此真的误删了一条文件收藏。
修复:进入多选前把当前 type 作为 hash 参数带上,多选页/分享页的返回箭头、"取消"、发送后
跳转都改用该参数换算的 tab,不再硬编码 'favorites-files'。

根因2:切换类型 tab 只更新 type,没有清空 selected——在"文件"tab 选中项目后切到"图片"tab,
头部仍显示旧的"已选择 N 项",实际操作的是已经不在当前列表里的旧类型收藏,删除/转发没有任何
可见提示。修复:切 tab 时一并清空 selected。

验证:真实浏览器逐步复现两个问题后确认修复生效——在"文本"tab进入多选保留文本列表、
切"图片"tab选择清零、"取消"正确回到多选前所在的图片tab;多选删除功能本身回归正常。
2026-09-06 13:38:25 +08:00
Your NameandClaude Sonnet 5 1228d6a534 补上组织架构部门蓝色高亮修复遗漏的CSS改动(.expanded重命名为.me)
上一个commit(1d47c586)只提交了JSX那部分(DeptRow/DeptNode改用containsMe),
对应的CSS改名(.organization-department.expanded → .me)当时被同一文件里
其他并发改动的暂存操作顶掉了,没有一起进去,导致上一commit单看JSX是完整的,
但CSS选择器名字还没改,实际不生效。这里补上,两个commit合起来才是完整修复。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:36:27 +08:00
Your NameandClaude Sonnet 5 1d47c586d3 修复组织架构页部门蓝色高亮跟"是否展开"挂钩,根节点被误判成"我的部门"
DeptRow原来的蓝色高亮(.organization-department.expanded)直接复用"展开/收起"
这个纯UI交互状态:depth 0的根节点默认就是展开的(DeptNode: useState(depth===0)),
导致根节点从打开页面那一刻起就被误染成"当前用户所在部门"的蓝色,与参考图
(docs/images/部门组织框架.png)不符——图里根节点"总裁办50人(一级)"明明是展开状态,
颜色却是黑色默认色,只有真正包含当前用户的"技术4人(二级)"是蓝色实心菱形。

根因是把"展开状态"和"是否是我的部门"这两个独立概念用同一个CSS类混在了一起。改为
新增containsMe(DeptNode根据自己已加载的members列表里有没有当前用户算出,与expanded
彻底解耦,expanded继续只管chevron方向),CSS类从.expanded改名成.me(与PersonRow已有的
.me保持同一套命名)。顺手把三处重复的"裸id/usr前缀id两种形式对比myUserId"的内联判断
提成共享的isMyUserId函数。

当前测试租户组织架构数据本身为空(GET /organization/departments、/members 都是真实
200+空数组,非账号权限问题——kcg051307交叉验证过同样为空),无法用真实多级树肉眼过一遍,
按测试要求改用桩数据验证:mock了总裁办→财务/销售/技术三个部门+技术下4个成员(含当前
用户)复现参考图的结构,展开技术后确认根节点保持黑色、技术正确变蓝,与参考截图逐像素
对照一致;修复前用同一份桩数据可复现根节点被误染蓝的问题。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:35:38 +08:00
Your NameandClaude Sonnet 5 a964d51ba5 修复回复/编辑消息触发风控弹窗时,"正在回复"预览条在弹窗结果出来前就被清空
现象:对一条历史消息点"回复",输入含链接的文本点发送,风控弹窗(安全提醒/警示级)
刚弹出的同时,composer 里"正在回复 XX"的预览条就已经消失了;如果这时候点"取消发送",
这条回复就被永久放弃——原来引用的消息内容和回复关系不再存在,也没有任何提示,唯一的
恢复办法是回到那条历史消息重新点一次"回复"。编辑消息(修改一条已发消息的内容后再次
包含链接)是同一根因的同款问题,只是编辑可以靠重新点"编辑"找回原文,没有回复那么彻底
不可逆。

根因:sendMessage() 原来在根据 this.state.reply / this.props.forwardMessage 组装好
msg/head 之后,同步、无条件地立刻调用了 this.handleCancelReply()(清空 reply/编辑
状态),这一步发生在下面的 URL 风控检查(POST /security/urls/check)之前——但风控
弹窗要等用户点"继续发送"或"取消发送"才会真正决定要不要走 doSend(),这中间的等待期里
UI 状态已经被提前清空了。

修法:把 handleCancelReply() 从三处提前调用点移到 doSend() 内部、真正调用
this.props.sendMessage(...) 之前。这样它只在真的要发送时才执行——不含链接的普通消息
里 doSend() 本来就是同步立即执行,时机与原来完全一样;命中风控的消息里,"继续发送"
时才会触发(此时清空回复状态是正确的,消息确实发出去了),"取消发送"时 doSend()
根本不会被调用,回复/编辑的预览条和引用内容都会完整保留,可以直接修改文本重试或
自己手动取消。转发场景不受影响:转发消息不会触发这条风控检查(originalText 恒为
null),移动调用时机对它是纯粹的时序等价替换。

验证:真实浏览器操作,对一条消息点"回复",输入含 https://example.com/a?token=abc123
的文本发送——警示弹窗弹出的同时预览条仍然可见;点"取消发送"后弹窗消失、预览条依旧
完整保留、composer 文本不变;再次点发送、这次点"我已知晓,继续发送",消息正确以
带引用卡片的回复形式发出,预览条在发送成功后才清空。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:32:59 +08:00
Your NameandClaude Sonnet 5 cdca509183 修复群邀请消息卡片丢弃"立即加入"按钮,被邀请人无法接受邀请
根因:POST /groups/{topic}/invitations 邀请成功后,后端会往邀请人与被邀请人的
单聊会话里发一条 x-im-type=system_action、template=group_invitation_created 的
消息,content.actions 里带着真实可用的 {type:"group_invitation_accept",
label:"立即加入", target_id:<invitation_id>}(对照参考图 6.3 加入群聊.png)。但
chat-message.jsx 原来对所有 system_action/system_notice 消息一律只取
content.text 渲染成纯文本,这个 action 数据被整体丢弃,消息只显示"邀请你加入群聊。"
没有任何按钮——im-api.js 里封装好的 acceptGroupInvitation() 因此从来没有调用点。

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:26:14 +08:00
Your NameandClaude Sonnet 5 a6014b84ee 修复桌面端拖动悬浮通话窗口后收窄浏览器至移动端断点,通话条卡在旧桌面坐标不跟随移动端布局
现象:视频通话最小化后处于桌面宽度(>640px)时,拖动悬浮小窗到任意位置(dragStart 会把
定位方式从 CSS 的 right/auto-left 切换成显式 left/top 内联样式,方便拖拽期间自由移动),
之后把浏览器窗口收窄到 640px 以下(真实场景:桌面用户把窗口拖窄到半屏和另一个应用并排),
悬浮条不会像预期那样贴顶变成通栏胶囊(base.css 里
#video-container.minimized:not(.audio-minimized) 的移动端规则:left:0/right:auto/
top:var(--caption-panel-height)/width:100%/height:11rem),而是继续停在拖拽结束时的旧
桌面坐标——因为内联 style 的优先级恒高于任何样式表选择器,不管后者的媒体查询是否匹配,
移动端规则里的 left/right/top 全部被内联值盖住,只有从未被内联样式接触过的 width/height
还能生效,导致宽/高不成比例、位置错位。

修复:CallPanel 新增 handleWindowResize,监听 window resize;每次触发时如果
matchMedia('(width<=640px)') 命中且当前处于 minimized,就清掉 containerRef 上手动写入的
left/right/top 内联样式,把定位权交还给样式表,让移动端规则完整生效。componentDidMount/
componentWillUnmount 里和已有的 pointerdown/move/up 监听一起注册/清理。从未拖拽过时这个
清理是空操作,不影响现有行为。

验证:真实 CallPanel 实例(React Fiber 直接获取),伪造 canvas/AudioContext 媒体流跑通整
个通话中状态,用 computer 工具做真实指针拖拽把悬浮窗从默认右上角拖到别处(确认
style.left/top 已写入、right:auto),再用 resize_window 把视口收窄到 375px 宽并确认应用
自身的 resize 监听正确触发(displayMobile 状态同步更新)——收窄后 style 只剩
"cursor: grab",left/right/top 均被清空,getComputedStyle 显示 left:0/top:53.33px(=
var(--caption-panel-height)按当前根字号换算)/width:375px(100%)/height:146.66px(11rem),
与移动端样式表规则完全一致;再放宽回桌面宽度确认悬浮窗正常回到默认右上角18rem方框位置,
未引入回归。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:22:06 +08:00
Your NameandClaude Sonnet 5 0748f30cc1 实名认证提交成功后补拉一次 GET,用服务端脱敏字段替换本地草稿回显(新增61)
现象(IM-后端问题清单-2026-09-04.md"新增61"):POST /security/realname/submissions
成功响应只有 {status, submission_id},从不带 masked_name/masked_id_number,前端此前
的兜底是回显用户本次会话里刚输入的原始姓名/证件号草稿——多数情况下看起来没问题,
但那是未脱敏的原始值,不是服务端认可的脱敏展示值,且只覆盖"这次会话内",换一个
从未在本会话提交过的场景(比如同一账号换设备/清缓存后第一次看提交结果)就没有任何
草稿可回退,只能空白。

之前的复核已经确认 GET /security/realname 从这个功能最初上线的 commit 起就一直带着
masked_name/masked_id_number 这两个字段,只是提交成功后从未再调用过它。

修法:submit() 里 POST 成功后追加一次 getRealnameStatus(),用响应里的
masked_name/masked_id_number 覆盖本地状态,展示服务端脱敏后的真实值;这次追加请求
失败时静默忽略(提交本身已经成功,不应该因为这次锦上添花的请求失败而回退),保留
原有的本会话草稿兜底作为这次请求解析完成前的过渡显示。

验证:注册全新测试账号(无历史提交),提交姓名"测试用户"+证件号
110105199003077856 后,抓包确认 POST 响应确实只有 {status,submission_id},紧接着
自动触发的 GET 响应带 masked_name:"测*"、masked_id_number:"110***********7856";
页面输入框读取到的实际 value 是这两个脱敏值,不是刚输入的原始文本,证实修复生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:21:05 +08:00
Your NameandClaude Sonnet 5 ded440078a 补上用户名注册路径缺失的"无法自助找回密码"提醒文案
需求文档 1.4 第5条硬性要求:用户名方式注册不发送短信,必须提示用户"未绑定手机号
无法自助找回密码,忘记密码后只能联系企业管理员重置"。这句话在注册表单里从未真正
写出来过——不是视觉上被样式盖住,是 JSX 里压根没有对应节点,选择用户名注册的人
完全不会被告知自己放弃了手机号找回密码的能力。

修法:在邀请码输入框下方补上这行提示,只在 usingPhone 为假(用户名注册路径)时
展示,不影响手机号注册路径(那条路径本来就能自助找回密码)。参考图"注册页面.png"
是两个字段都还没交互的初始态,不代表这条路径相关的提示不应该出现。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:20:29 +08:00
39 changed files with 1706 additions and 1620 deletions
+119 -12
View File
@@ -1590,11 +1590,18 @@
font-size: 1.3rem;
}
.organization-department.expanded {
/* Renamed from .expanded -- this rule drives the blue "this is my department" highlight from
docs/images/部门组织框架.png (only the department the current user directly belongs to, e.g.
"技术4人" in the reference, not every expanded node -- the reference's always-expanded root
"总裁办50人" stays plain black). Was keyed off the same class as the expand/collapse chevron
state, which colored ANY expanded department blue including the root, on every page load, with
nothing to do with where the current user actually is -- see workplace-collaboration-view.jsx's
DeptRow/DeptNode comments for the live repro (via stub data, this tenant's real org tree is empty). */
.organization-department.me {
color: #2094f3;
}
.organization-department.expanded > .material-icons:first-child {
.organization-department.me > .material-icons:first-child {
color: #2094f3;
}
@@ -3608,6 +3615,35 @@ img.channel {
color: var(--clr-primary-accent);
}
/* 6.3 加入群聊.png: the one system_action message (group_invitation_created) that carries a real
accept button -- see chat-message.jsx's renderGroupInvitationCard. */
.message-group-invitation button {
display: block;
margin-top: 0.4rem;
padding: 0.3rem 0.9rem;
border: 0;
border-radius: 1rem;
color: #fff;
background: var(--clr-primary-accent);
font-size: 0.85rem;
}
.message-group-invitation button:disabled {
opacity: 0.6;
}
.message-group-invitation em {
display: block;
margin-top: 0.4rem;
color: var(--clr-text-secondary);
font-style: normal;
font-size: 0.85rem;
}
.message-group-invitation-error {
display: block;
margin-top: 0.25rem;
color: #df4e46;
font-size: 0.78rem;
}
/* Multi-select mode (Ch.4 4.15-4.16). */
.select-checkbox {
align-self: center;
@@ -4355,9 +4391,15 @@ button.full-screen .material-icons {
width: 10rem;
height: 10rem;
z-index: 2;
/* Draggable while in a fullscreen connected video call (call-panel.jsx's dragStart); the grab
cursor is the only hint of that before a viewer ever tries, since unlike the minimized bubble
there's no other affordance. Not shown while [disabled] (audio-only calls / minimized, where
dragging this box does nothing -- see call-panel.jsx). */
cursor: grab;
}
.call-party.self[disabled] {
cursor: unset;
display: none;
}
@@ -4611,6 +4653,17 @@ video {
user-select: none;
}
/* 群系统事件(成员加入/角色变更/公告发布等),docs/images/内容沟通对话3-开启进群验证与拉人状态.png
显示为不带背景的纯居中灰字,跟需要背景色块区分"这是一条独立提示"的 date/deleted 两种 .meta
变体不一样——公告/进群这类事件本身已经在句子里说明了是系统提示,不需要额外的气泡背景。 */
.meta .bubble.system {
max-width: 90%;
padding: 0.3rem 0.5rem;
background-color: transparent;
font-size: 0.8rem;
text-align: center;
}
/* Left triangle placed bottom left flush. */
.left .bubble.tip::before {
content: '';
@@ -9207,6 +9260,7 @@ div.toast.show {
background: #ef4653;
font-size: 0.7rem;
font-style: normal;
line-height: 1;
}
.people-group h2 {
@@ -9339,6 +9393,13 @@ div.toast.show {
border-bottom: 1px solid #eceef1;
}
/* .people-request-list article is now clickable (role="button", opens the real chat -- see
contact-management-view.jsx's FriendRequests) -- the others in the selector above aren't, so this
is its own rule rather than joining the block just above. */
.people-request-list article[role="button"] {
cursor: pointer;
}
.people-request-list p,
.group-request-list p {
max-width: 24rem;
@@ -11362,6 +11423,62 @@ div.toast.show {
font-size: 0.72rem;
}
/* Same generic confirm-sheet look this app already uses in personal-settings-view.jsx
(.personal-sheet-backdrop/.personal-confirm-sheet, 46a98782) and group-management-view.jsx
(.group-sheet-backdrop/.group-confirm-sheet, 4773ee5f) -- each view file keeps its own
identically-styled copy under its own class prefix rather than a shared cross-file component. */
.library-sheet-backdrop {
display: grid;
position: absolute;
z-index: 70;
inset: 0;
place-items: center;
padding: 1rem;
background: rgba(22,26,31,0.52);
}
.library-confirm-sheet {
width: min(100%, 25rem);
padding: 1.25rem;
border-radius: 0.5rem;
background: #fff;
text-align: center;
}
.library-confirm-sheet > .material-icons {
color: #4d73aa;
font-size: 2.8rem;
}
.library-confirm-sheet h2 {
margin: 0.65rem 0;
font-size: 1.15rem;
}
.library-confirm-sheet p {
margin: 0.7rem 0 1rem;
color: #5c646e;
line-height: 1.6;
text-align: left;
}
.library-confirm-sheet button {
width: 100%;
min-height: 2.9rem;
margin: 0.45rem 0 0;
padding: 0 1rem;
border: 0;
border-radius: 0.35rem;
box-shadow: none;
color: #4d5660;
background: #f0f2f4;
}
.library-confirm-sheet button.danger {
color: #fff;
background: #d84950;
}
.library-section-title {
width: min(100%, 52rem);
margin: 0 auto;
@@ -12680,16 +12797,6 @@ div.toast.show {
height: 4.3rem;
}
.people-request-list article,
.group-request-list article {
align-items: flex-start;
}
.people-request-list article > span,
.group-request-list article > span {
flex-direction: column;
}
.people-profile-actions {
grid-template-columns: 1fr;
}
+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
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1523,7 +1523,7 @@
"obsolete": false
},
"menu_item_audio_call": {
"translation": "稱呼",
"translation": "語音電話",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
+1 -1
View File
@@ -1523,7 +1523,7 @@
"obsolete": false
},
"menu_item_audio_call": {
"translation": "称呼",
"translation": "语音电话",
"defaultMessage": "Call",
"description": "Start audio call",
"missing": false,
+22 -1
View File
@@ -43,6 +43,7 @@ class AccSecurityView extends React.Component {
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
this.handleLogoutRequest = this.handleLogoutRequest.bind(this);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
}
@@ -89,6 +90,26 @@ class AccSecurityView extends React.Component {
);
}
// "退出登录"这个按钮原来是 onClick 里直接 `this.props.onLogout()`,没有任何二次确认——同一个文件里
// 紧挨着的"删除帐户"按钮反而是走 onShowAlert 弹确认框的(见上面 handleDeleteAccount)。本页
// ('security' 路由)并不是废弃页面:account-settings-view.jsx、info-view.jsx 的"安全"入口,以及
// 第2章系统消息列表里"前往安全设置"跳转按钮(message-center-view.jsx)都会真的落到这个页面,
// 跟 personal-settings-view.jsx 的"设置"页(那边的退出登录已经在别的改造里换成了自己的
// ConfirmSheet/LogoutConfirm)是两条并存的独立路径——用户从系统消息点"前往安全设置"进来,单击一下
// 图标就会被立即退出登录,且没有任何撤销机会。改成跟"删除帐户"一样走 onShowAlert,文案与
// personal-settings-view.jsx 的 LogoutConfirm 保持一致,不新造一套措辞。
handleLogoutRequest(e) {
e.preventDefault();
this.props.onShowAlert(
'退出登录',
'确定要退出当前登录吗?',
(_ => this.props.onLogout()),
null, // use default text "OK"
true, // Show Reject button
null // use default text "Cancel"
);
}
render() {
return (
<>{this.state.showPermissionEditorFor ?
@@ -143,7 +164,7 @@ class AccSecurityView extends React.Component {
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
<a href="#" className="danger flat-button" onClick={this.handleLogoutRequest}>
<i className="material-icons">exit_to_app</i> &nbsp;<FormattedMessage id="button_logout"
defaultMessage="Logout" description="Button [Logout]" />
</a>
+45 -6
View File
@@ -486,6 +486,13 @@ function Register({errorText, errorLevel, persist, onImRegisterRequest, onPersis
没有它,一个真实有效、首字母是小写的邀请码会被静默改错且用户毫无察觉。 */}
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
placeholder="请输入邀请码(非必填)" error={errors.invite} autoCapitalize="off" autoCorrect="off" />
{/* 需求文档 1.4 第5条硬性要求:"用户名方式不发送短信,必须填写有效邀请码;注册前提示
'未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置'"——原来这句话在整个
注册表单里完全没有出现过(不是视觉遗漏,是从未写过对应的 JSX),用户名注册的人完全不知道
自己放弃了手机号找回密码的能力。只在用户名路径(未填手机号)显示,不打扰手机号注册路径
(那条路径本来就能自助找回密码,不需要这条提醒),参考图(注册页面.png)本身是两个字段
都还没交互的初始态,不代表这条路径相关提示不应该出现。 */}
{!usingPhone ? <p className="auth-help">未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置</p> : null}
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'}
</button>
@@ -671,6 +678,16 @@ function Realname({authenticated = false}) {
const data = await submitRealname({name: name.trim(), id_type: 'cn_id', id_number: idNumber.toUpperCase()});
setStatus(data.status || 'pending');
setError('');
// The submissions POST only ever returns {status, submission_id} -- never masked_name/
// masked_id_number -- but GET /security/realname has carried both fields since this feature's
// original commit (confirmed against server source), we just weren't calling it again right
// after a successful submit. Best-effort: the submission itself already succeeded above, so a
// failure here shouldn't take that away from the user -- it just means the pre-existing
// fallback below (this session's own typed draft) keeps covering the display like before.
getRealnameStatus().then(fresh => {
if (fresh.masked_name) setMaskedName(fresh.masked_name);
if (fresh.masked_id_number) setMaskedIdNumber(fresh.masked_id_number);
}).catch(() => {});
} catch (err) {
setError(err.message || '提交失败,请稍后重试');
} finally {
@@ -694,12 +711,14 @@ function Realname({authenticated = false}) {
一旦账号真的走到 verified 状态就会显示一句参考图里根本不存在的标题。 */}
<h2>请您使用有效身份证信息认证</h2>
<form onSubmit={submit}>
{/* 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. */}
{/* submitRealname's own success response never carries masked_name/masked_id_number (only
{status, submission_id}) -- submit() above now follows up with a GET /security/realname
to fetch the server's authoritative masked values right after a successful submission, so
this local-draft fallback (`name`/`idNumber`, still sitting in state from what was just
typed) only matters for the brief window before that GET resolves. It still degrades to
genuinely blank on a fresh page load with no draft in this session and no masked value
from a prior GET either (e.g. a submission made in an earlier session) -- that residual
case isn't fixable from here since it depends on the follow-up GET actually having run. */}
<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}
@@ -806,6 +825,26 @@ export default function AuthOnboardingView({
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
}) {
const tenantName = sessionStorage.getItem('im-tenant-name') || '';
// errorText/errorLevel is one flat, top-level (tinode-web.jsx) piece of state shared by every
// view in the whole app -- it's never tied to which sidepanel is showing. Login is the only view
// here that already clears it on its own (its "切换企业" link calls onError() before navigating),
// because it was written knowing it can carry a leftover error INTO the tenant screen. Every other
// transition between these auth views was still missing that: submit a bad invite code on
// Register (errorText='邀请码错误,请重新输入', view stays 'register', shown correctly), then just
// click "已有账号?登录" -- Login mounts and, since it renders the exact same errorText/errorLevel
// props, shows that same registration error as if it were a login failure, with zero user action
// on this new page. Same the other way: a stale "账号或密码错误" from Login is still sitting there
// to greet a fresh Register visit. Fixing every call site that navigates between these views
// (TenantEntry/Login/Register/... several of which don't even receive onError as a prop today)
// would be a lot of scattered one-off edits for the same bug; clearing it centrally whenever the
// resolved `view` itself changes covers all of them (button clicks and browser back/forward alike)
// from the one place they all already funnel through.
React.useEffect(() => {
if (onError) onError();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
switch (view) {
case 'tenant': return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
case 'login': return <Login tenantName={tenantName} login={login} loginDisabled={loginDisabled} persist={persist}
+71 -11
View File
@@ -172,7 +172,7 @@ function requestStatusLabel(item) {
return null;
}
function FriendRequests({onToast}) {
function FriendRequests({onToast, onTopicSelected, onCreateTopic}) {
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
@@ -189,13 +189,19 @@ function FriendRequests({onToast}) {
onToast(err.message || '操作失败');
});
};
// 参考图 个人对话框(对方的状态)/(我的状态).png:申请的验证留言应该在真实聊天窗口里以消息
// 气泡展示,横幅提示("以上是打招呼的消息"/"好友申请正在等待处理")已经在 messages-view.jsx
// 里做对了,但这个列表原来每一行完全没有 onClick,点了毫无反应——点进去看这条留言/横幅的入口
// 一直不存在。跟 Profile 的 openChat 用同一个"优先走 onCreateTopic 保证冷启动也能订阅"的写法。
const openChat = item => (onCreateTopic || onTopicSelected) &&
(onCreateTopic || onTopicSelected)(item.direction === 'inbound' ? item.applicant_id : item.target_user_id);
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">{loadError ? '加载失败,请重试' : '暂无新朋友申请'}</p> : null}
{items.map(item => <article key={item.request_id}>
{items.map(item => <article key={item.request_id} role="button" tabIndex={0} onClick={() => openChat(item)}>
<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>
{item.status === 'pending' && item.direction === 'inbound' ?
<span><button type="button" onClick={() => handle(item, false)}>拒绝</button><button type="button" className="primary" onClick={() => handle(item, true)}>通过验证</button></span> :
<span><button type="button" onClick={e => { e.stopPropagation(); handle(item, false); }}>拒绝</button><button type="button" className="primary" onClick={e => { e.stopPropagation(); handle(item, true); }}>通过验证</button></span> :
<em>{requestStatusLabel(item)}</em>}
</article>)}
</main></div>;
@@ -356,6 +362,20 @@ function Verification({uid, onToast}) {
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(data => { writeCachedData('profile-seed:' + uid, data); setProfile(data); }).catch(() => {});
// getUserProfile 403s for anyone who isn't currently a friend (server privacy default) -- same
// gap Profile already works around below. Without this, a `profile-seed` left over from *earlier
// in the same session* (e.g. this pair really were friends before one side unfriended them, or a
// request was sent and then rejected) stayed on screen forever: alreadyFriend/alreadyPending read
// straight off `profile.relationship`, and the 403 catch above has nothing to correct it with.
// Confirmed live: unfriend someone, then re-open this exact page for them (still reachable via
// their still-cached, now-stale seed) -- it kept claiming "你们已经是好友了" and hid the compose
// box entirely, with no way to send a new request short of a full page reload to drop the cache.
// getConversationContext isn't gated on friendship and reports the true current relationship.
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
}
}).catch(() => {});
}, [uid]);
const alreadyPending = profile && (profile.relationship === 'pending_outbound' || profile.relationship === 'pending_inbound');
const alreadyFriend = profile && profile.relationship === 'friend';
@@ -366,7 +386,17 @@ function Verification({uid, onToast}) {
// Patch the seed Profile will re-read on remount -- its own background getUserProfile refresh
// can't do this for us, since a stranger-turned-pending peer still fails that gated endpoint
// the exact same way a stranger does (server only exempts an established "friend").
writeCachedData('profile-seed:' + uid, {...(readCachedData('profile-seed:' + uid) || {}), relationship: 'pending_outbound'});
// Must also flip capabilities.can_add_friend to false here, not just relationship: Profile's
// canAdd render check (contact-management-view.jsx ~line 444) reads capabilities.can_add_friend,
// not relationship, directly. Leaving the stale can_add_friend:true (copied verbatim from the
// pre-request search-result seed) meant Profile kept showing an active "添加好友" button after
// a request was already sent -- confirmed live: send request, navigate back to this profile
// (the verification screen's own auto-redirect 800ms later, or its back button, both land here),
// and the button was still clickable, capable of firing a second, backend-undeduped request
// (see 新增44 in the backend issue list) purely because of this front-end staleness.
const priorSeed = readCachedData('profile-seed:' + uid) || {};
writeCachedData('profile-seed:' + uid, {...priorSeed, relationship: 'pending_outbound',
capabilities: {...priorSeed.capabilities, can_add_friend: false}});
window.setTimeout(() => navigate('group-member-profile', uid), 800);
}).catch(err => onToast(err.message || '发送失败')).finally(() => setSending(false));
};
@@ -382,7 +412,7 @@ function Verification({uid, onToast}) {
</main></div>;
}
function Profile({uid, friend, onTopicSelected, onToast}) {
function Profile({uid, friend, onTopicSelected, onCreateTopic, onToast}) {
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
// A failed fetch with no seed to fall back on previously left this stuck on "加载中…" forever --
// tracked separately from `profile` itself (which legitimately stays null while genuinely still
@@ -410,9 +440,19 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
// handles below. getConversationContext resolves for a P2P conversation regardless of block
// status and correctly reports the real relationship, so it's used here purely to refresh that
// one field on top of whatever getUserProfile/the seed cache already provided for the rest.
//
// 同时必须把 ctx.capabilities 一起合并进去,不能只更新 relationship:canAdd 这个渲染判断读的是
// profile.capabilities.can_add_friend,不是 relationship 本身。实测复现:从陌生人搜索结果进来的
// seed 一直带着 capabilities.can_add_friend=true(搜索时 can_add 确实是 true),发送好友申请后
// relationship 变成 pending_outbound,可这里原来只覆盖 relationship 字段,capabilities 原样保留
// 着申请前的 can_add_friend:true——canAdd 分支判断不看 relationship,于是"添加好友"按钮在申请
// 已发出后继续显示、可以重复点击再次发起申请(配合后端不去重的缺口——新增44——实际造成重复申请)。
// ctx.capabilities 本身就是服务端针对当前 relationship 算出来的权威值(此时 can_add_friend 已经
// 是 false),直接一起合并即可,不需要在前端另外推导一套"这些 relationship 不能加好友"的规则。
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
setProfile(current => current ? {...current, relationship: ctx.relationship,
capabilities: ctx.capabilities || current.capabilities} : current);
}
}).catch(() => {});
}, [uid]);
@@ -444,7 +484,17 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
// Real native Tinode p2p topic name (from my side) is just the peer's own user_id ("usrXXXX") --
// NOT `profile.topic` (a separate "p2pXXXX" identifier the custom REST layer uses internally,
// which Tinode.topicType() doesn't recognize, so selecting it left the message pane blank).
const openChat = () => onTopicSelected(uid);
//
// Must go through onCreateTopic (tinode-web.jsx's handleStartTopicRequest), not the plain
// onTopicSelected -- confirmed live: a freshly-accepted friend with zero prior message history has
// no locally cached p2p topic yet, and onTopicSelected (handleTopicSelected) only *selects* a topic,
// it never sends the native {sub} request that actually creates/joins it. messages-view.jsx's own
// isWriter stays stuck at its default `false` (no ACS ever arrives because nothing subscribed),
// permanently showing "消息已禁用" with no compose box and no way to send the first message --
// 100% reproducible for every real "just became friends, say hi" case, not an edge case.
// handleStartTopicRequest already guards the case this topic *does* have history (isTopicCached),
// delegating straight to handleTopicSelected then, so this is safe to call unconditionally.
const openChat = () => (onCreateTopic || onTopicSelected)(uid);
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'}
right={isFriend ? '设置' : null} onRight={() => navigate('friend-settings', uid)} />
<main className="people-profile">
@@ -467,7 +517,17 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
<button type="button" onClick={openChat}><Icon>call</Icon>音视频通话</button>
</div> :
canAdd ? <button type="button" className="people-primary" onClick={() => navigate('friend-verification', uid)}><Icon>person_add_alt</Icon>添加好友</button> :
<p className="people-empty-hint">{cannotAddReason}</p>}
// cannotAddReason 覆盖的四种关系(blocked_by_peer/blocked_by_me/pending_inbound/
// pending_outbound)在 messages-view.jsx 里都已经有对应的、真实可用的聊天视图(对方发来的
// 留言气泡+"以上是打招呼的消息"横幅,或"好友申请正在等待处理"横幅,或拉黑后仍可输入、发送
// 失败再展示具体原因)——原来这个非好友分支只显示 cannotAddReason 这段纯文字,没有任何
// 入口能点进去看。同一套 openChat(优先 onCreateTopic 保证冷启动)。按钮不套进
// .people-profile-actions 的两列grid(那是给"发消息+音视频通话"两个平级按钮设计的),
// 单独一个满宽按钮,跟上面纯文字提示分两行。
<>
<p className="people-empty-hint">{cannotAddReason}</p>
<button type="button" className="people-primary" onClick={openChat}><Icon>chat_bubble_outline</Icon>查看聊天</button>
</>}
</main>
</div>;
}
@@ -625,17 +685,17 @@ function ConfirmSheet({type, uid, tinode, onToast}) {
</div>;
}
export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onOpenScanner}) {
export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onCreateTopic, onOpenScanner}) {
const [toast, setToast] = React.useState('');
const uid = hashParams && hashParams.uid;
let content;
if (view === 'address-book') content = <AddressBook myUserId={myUserId} onTopicSelected={onTopicSelected} />;
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} onTopicSelected={onTopicSelected} onCreateTopic={onCreateTopic} />;
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} onToast={setToast} onOpenScanner={onOpenScanner} />;
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
else if (view === 'friend-qr') content = <FriendQr uid={uid} onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onCreateTopic={onCreateTopic} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'block-list') content = <BlockList onToast={setToast} />;
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" uid={uid} tinode={tinode} onToast={setToast} />;
+71 -15
View File
@@ -266,10 +266,17 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect, query, onToa
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links', image: 'favorites-image', video: 'favorites-video'};
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
function Favorites({view, tinode, onToast}) {
function Favorites({view, hashParams, tinode, onToast}) {
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : view === 'favorites-image' ? 'image' : view === 'favorites-video' ? 'video' : 'all';
const selecting = view === 'favorites-select';
const [type, setType] = React.useState(selecting ? 'file' : initialType);
// 'favorites-select' 是所有类型 tab 共用的同一个路由,本身不带任何类型信息——原来不管进入
// 多选前停在哪个 tab,一律硬编码回退成 'file'。实测复现:在"文本"tab 点"多选",列表和头部
// 的"已选择 N 项"计数都静默换成了"文件"tab 的数据(tab 高亮也跟着跳到"文件"),用户在视觉上
// 完全看不出发生了什么,勾选/删除会作用在自己压根没看到、以为还是文本收藏的文件项上——测试
// 过程中真的因为这个把一条本不该删的文件收藏删掉了。现在"多选"按钮把当前 type 作为 hash 参数
// 带过去,这里读回来,保留进入多选前用户实际所在的 tab;只有直接深链到 'favorites-select'
// 且没带 type 参数时才落回原来的 'file' 默认值。
const [type, setType] = React.useState(selecting ? ((hashParams && hashParams.type) || 'file') : initialType);
// 输入值与实际发请求用的值分开:原来 query 直接进 load 的依赖数组,输入 6 个字的关键词就打
// 6 次接口,而且返回顺序没有保证——后发的短前缀请求可能后到、把正确结果覆盖掉。
const [queryInput, setQueryInput] = React.useState('');
@@ -283,6 +290,16 @@ function Favorites({view, tinode, onToast}) {
const [items, setItems] = React.useState(() => readCachedData('favorites:' + type) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
// A failed initial load (no cached list to fall back on) previously left this stuck on "加载中…"
// forever -- the catch handler only fired a toast, which fades in 2s, and `items` never left null
// so the loading branch below never had anything else to show. Same fix already applied to this
// file's own DownloadPage/GroupFiles(group-management-view.jsx)/Profile(contact-management-view.jsx)
// for the identical reason; Favorites/History were missed. Concretely hit live: favoriting a large
// (out-of-band) file attachment makes the *server* 500 on GET /favorites?type=all from then on
// (a real backend bug, tracked separately -- not something the frontend can prevent), and every
// subsequent visit to "全部" silently hung on the loading spinner with only a 2s toast, no way to
// tell "still loading" from "never going to load", no retry affordance.
const [loadError, setLoadError] = 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
@@ -295,6 +312,7 @@ function Favorites({view, tinode, onToast}) {
const requestId = ++requestIdRef.current;
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
if (requestIdRef.current !== requestId) return;
setLoadError(false);
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query) writeCachedData('favorites:' + type, list);
@@ -302,9 +320,14 @@ function Favorites({view, tinode, onToast}) {
});
setCursor(data.next_cursor || '');
setHasMore(!!data.next_cursor);
}).catch(err => onToast(err.message || '加载失败'));
}).catch(err => {
if (requestIdRef.current !== requestId) return;
onToast(err.message || '加载失败');
if (!append) setLoadError(true);
});
}, [type, query, cursor]);
React.useEffect(() => {
setLoadError(false);
setItems((!query && readCachedData('favorites:' + type)) || null);
load(false);
}, [type, query]);
@@ -324,8 +347,13 @@ function Favorites({view, tinode, onToast}) {
});
}, [items]);
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
const deleteSelected = () => {
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
// 原来是裸 window.confirm()——跟这个工程已经修过的另外两处同一类问题(46a98782"退出登录"、
// 4773ee5f"解散群组")一样:跟本项目其它地方的自定义确认弹层视觉不一致,且部分内嵌WebView/
// kiosk环境会直接禁用同步对话框(confirm()恒返回false,删除被静默拦下、用户毫无感知)。
// 改成两段式 state:点"删除"先进"确认中"态渲染弹层,弹层里的"确定"才真正调用删除接口。
const [confirmingDelete, setConfirmingDelete] = React.useState(false);
const confirmDelete = () => {
setConfirmingDelete(false);
batchDeleteFavorites(selected).then(result => {
const okIds = new Set(((result && result.items) || []).filter(r => r.ok).map(r => r.favorite_id));
setItems(values => {
@@ -359,13 +387,21 @@ function Favorites({view, tinode, onToast}) {
};
const list = items || [];
return <div className="library-page">
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'}
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
{/* "返回"/"取消"都要回到多选前那个类型 tab,而不是硬编码的"文件"——TYPE_ROUTE 查不到时
(比如 type 还是初始的 'all') 才退回 'favorites-files',跟原来的兜底保持一致。 */}
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? (TYPE_ROUTE[type] || 'favorites-files') : 'messages-home'}
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? (TYPE_ROUTE[type] || 'favorites-files') : 'favorites-select', selecting ? undefined : {type})} />
<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) navigateReplace(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
// 多选模式下切 tab 之前,selected 存的是"收藏 id 的集合",不带类型信息——原来只切 type、
// 不清 selected,导致在"文件"tab 选中 2 项后切到"图片"tab,头部仍显示"已选择 2 项"、点
// 删除/转发会真的作用在两个已经从当前列表消失、用户此刻根本看不到也无法勾选/取消的文件上,
// 且没有任何提示告诉用户这两项还算在内。多选场景下天然只该管"当前这个列表里勾了什么",
// 跨 tab 静默累加选择集不是这里的预期行为(对照参考图"收藏—文件(多选)"也只画了单一
// 类型列表内的勾选,没有跨 tab 累加的设计)。
onClick={() => { setType(id); setSelected([]); 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> :
{items === null ? <p className="library-empty-hint">{loadError ? '加载失败,请重试' : '加载中…'}</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)}
@@ -375,8 +411,13 @@ function Favorites({view, tinode, onToast}) {
</main>
{selecting ? <nav className="library-batch-bar">
<button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share', {ids: selected.join(',')})}><Icon>share</Icon><span>转发</span></button>
<button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button>
<button type="button" disabled={!selected.length} onClick={() => setConfirmingDelete(true)}><Icon>delete_outline</Icon><span>删除</span></button>
</nav> : null}
{confirmingDelete ? <div className="library-sheet-backdrop"><section className="library-confirm-sheet" role="dialog" aria-modal="true">
<Icon>delete_outline</Icon><h2>删除收藏</h2><p>确定删除已选择的 {selected.length} 项收藏吗?</p>
<button type="button" className="danger" onClick={confirmDelete}>确定</button>
<button type="button" onClick={() => setConfirmingDelete(false)}>取消</button>
</section></div> : null}
</div>;
}
@@ -410,7 +451,9 @@ function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
return forTarget.length > 0 && forTarget.every(r => r.status === 'sent');
}).length;
onToast(successTargets === selected.length ? `已分享给 ${selected.length} 个会话` : `${successTargets}/${selected.length} 个会话分享成功`);
window.setTimeout(() => navigate('favorites-files'), 700);
// 发完之后回到分享前那个类型 tab,而不是硬编码的"文件"——preview 是这批收藏里第一条的详情,
// 拿它的 type 就够找回对应 tab;preview 还没加载完时退回原来的 'favorites-files' 兜底。
window.setTimeout(() => navigate(TYPE_ROUTE[preview && preview.type] || 'favorites-files'), 700);
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
};
const previewAtt = preview && firstAttachment(preview.snapshot && preview.snapshot.content);
@@ -418,7 +461,10 @@ function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
(!query || topicDisplayName(c.name, c.public, c.name || '').toLowerCase().includes(query.toLowerCase())));
const nameOf = uid => { const t = contacts.find(c => c.name === uid); return (t && topicDisplayName(t.name, t.public, uid)) || uid; };
return <div className="library-page">
<Header title="分享" back="favorites-select" />
{/* 同上一条注释——"favorites-select" 不带类型信息,点这里的返回箭头原来会静默把多选列表
换成硬编码的"文件"tab。preview.type 是这批收藏第一条的真实类型,带回去能让返回箭头落回
分享前实际所在的 tab。 */}
<Header title="分享" back="favorites-select" backParams={preview ? {type: preview.type} : undefined} />
{!confirming ? <>
<Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" />
<p className="library-section-title">最近聊天</p>
@@ -594,6 +640,10 @@ function History({view, hashParams, myUserId, onToast}) {
const [items, setItems] = React.useState(() => (cacheKey && readCachedData(cacheKey)) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
// Same "stuck on 加载中… forever" gap as Favorites' loadError above, same fix -- a failed initial
// load with nothing cached to fall back on left this on the loading branch permanently, with only
// a 2s toast (already faded by the time anyone looks back) as the only sign anything went wrong.
const [loadError, setLoadError] = 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
@@ -608,15 +658,21 @@ function History({view, hashParams, myUserId, onToast}) {
getTopicMedia(topic, type, append ? cursor : '');
call.then(data => {
if (requestIdRef.current !== requestId) return;
setLoadError(false);
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query && cacheKey) writeCachedData(cacheKey, list);
return list;
});
setCursor(data.next_cursor || ''); setHasMore(!!data.next_cursor);
}).catch(err => onToast(err.message || '加载失败'));
}).catch(err => {
if (requestIdRef.current !== requestId) return;
onToast(err.message || '加载失败');
if (!append) setLoadError(true);
});
}, [topic, view, query, cursor]);
React.useEffect(() => {
setLoadError(false);
setItems((!query && cacheKey && readCachedData(cacheKey)) || null);
load(false);
}, [topic, view, query]);
@@ -667,7 +723,7 @@ function History({view, hashParams, myUserId, onToast}) {
{view === 'history-search' ? <Search value={queryInput} onChange={setQueryInput} placeholder="搜索当前会话" /> : null}
<HistoryNav active={view} />
<main className="library-history-content">
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
{items === null ? <p className="library-empty-hint">{loadError ? '加载失败,请重试' : '加载中…'}</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 类型
@@ -718,7 +774,7 @@ function History({view, hashParams, myUserId, onToast}) {
export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
const [toast, setToast] = React.useState(''); let content;
if (view.startsWith('favorites')) content = <Favorites view={view} tinode={tinode} onToast={setToast} />;
if (view.startsWith('favorites')) content = <Favorites view={view} hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else if (view === 'favorite-share') content = <FavoriteShare hashParams={hashParams} myUserId={myUserId} chatList={chatList} onToast={setToast} />;
else if (view.startsWith('file-download')) content = <DownloadPage hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <History view={view} hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
-233
View File
@@ -1,233 +0,0 @@
// Account registration.
import React, { Suspense } from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarCrop from '../widgets/avatar-crop.jsx';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import PhoneEdit from '../widgets/phone-edit.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { theCard } from '../lib/utils.js';
import { truncateString } from '../lib/strformat.js';
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
MAX_TITLE_LENGTH } from '../config.js';
export default class CreateAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
login: '',
password: '',
meth: '',
email: '',
tel: '',
fn: '', // full/formatted name
imageUrl: null,
uploadUrl: null,
newAvatar: null,
newAvatarMime: null,
buttonDisabled: false,
saveToken: LocalStorageUtil.getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePhoneChange = this.handlePhoneChange.bind(this);
this.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
this.uploadAvatar = this.uploadAvatar.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
// Connection will trigger change by changing the this.props.serverVersion.
props.tinode.connect()
.catch(err => {
this.props.onError(err.message, 'err');
});
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(password) {
this.setState({password: password});
}
handleEmailChange(e) {
this.setState({meth: 'email', email: e.target.value});
}
handlePhoneChange(number) {
this.setState({meth: 'tel', tel: number});
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handleImageChanged(mime, img) {
this.setState({newAvatar: img, newAvatarMime: mime});
if (!img) {
// User cleared the image.
this.setState({imageUrl: null, uploadUrl: null});
}
}
handleToggleSaveToken() {
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.props.onCreateAccount(
this.state.login.trim(),
this.state.password.trim(),
theCard(truncateString(this.state.fn.trim(), MAX_TITLE_LENGTH), this.state.uploadUrl),
{
'meth': this.state.meth,
'val': this.state.meth == 'email' ?
this.state.email :
this.state.meth == 'tel' ? this.state.tel : null
}
);
}
// AvatarCropView calls this method when the user has cropped the image.
handleAvatarCropped(mime, blob, width, height) {
const url = blob ? URL.createObjectURL(blob) : null;
this.setState({newAvatar: null, newAvatarMime: null});
if (blob) {
this.uploadAvatar(mime, blob, width, height);
}
}
handleAvatarCropCancel() {
this.setState({newAvatar: null, newAvatarMime: null});
}
// Utility method for converting cropped avatar blob to bytes for sending inband or
// for uploading it to the server out of band.
uploadAvatar(mime, blob, width, height) {
const readyToUpload = image => {
let {mime, blob} = image;
this.setState({imageUrl: URL.createObjectURL(blob), buttonDisabled: true});
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
uploader.upload(blob, 'newacc')
.then(url => this.setState({uploadUrl: url}))
.catch(err => this.props.onError(err.message, 'err'))
.finally(_ => this.setState({buttonDisabled: false}));
} else {
// Convert blob to base64-encoded bits.
blobToBase64(blob)
.then(b64 => this.setState({uploadUrl: makeImageUrl({data: b64.bits, type: mime})}))
.finally(_ => this.setState({buttonDisabled: false}));
}
};
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
// Avatar is not square or too large even after cropping. Shrink it and make square.
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true)
.then(scaled => readyToUpload(scaled))
.catch(err => this.props.onError(err.message, 'err'));
} else {
readyToUpload({mime: mime, blob: blob, width: width, height: height});
}
}
render() {
if (this.state.newAvatar) {
return (
<AvatarCrop
avatar={this.state.newAvatar}
mime={this.state.newAvatarMime}
onSubmit={this.handleAvatarCropped}
onCancel={this.handleAvatarCropCancel}
onError={this.props.onError} />
);
}
let submitClasses = 'primary';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form className="panel-form-column" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="umn">
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Placeholer for username/login">{
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
}</FormattedMessage>
<FormattedMessage id="password_prompt" defaultMessage="Password"
description="Placeholder/prompt for entering password">{
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
value={this.state.password} onFinished={this.handlePasswordChange}
required={true} />
}</FormattedMessage>
</div>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.imageUrl}
onImageUpdated={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="panel-form-row">
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
description="Input placeholder for person's full name">{
(full_name_prompt) => <input type="text" placeholder={full_name_prompt} autoComplete="name"
value={this.state.fn} onChange={this.handleFnChange} required/>
}</FormattedMessage>
</div>
{this.props.reqCredMethod == 'email' ?
<div className="panel-form-row">
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
description="Input placeholder for email entry">{
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
value={this.state.email} onChange={this.handleEmailChange} required/>
}</FormattedMessage>
</div>
: this.props.reqCredMethod == 'tel' ?
<><div className="panel-form-row">
<label className="small gray"><FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
description="Prompt for entering a mobile phone number" /></label>
</div>
<div className="panel-form-row">
<PhoneEdit
autoFocus={false}
onShowCountrySelector={this.props.onShowCountrySelector}
onSubmit={this.handlePhoneChange} />
</div></>
: null
}
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
description="Label for a checkbox">{
(stay_logged_in) => <label htmlFor="save-token">&nbsp;{stay_logged_in}</label>
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit" disabled={this.state.buttonDisabled}>
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
description="Create account button [Sign Up]" />
</button>
</div>
</form>
);
}
};
+124 -11
View File
@@ -10,7 +10,7 @@ import {
updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
getGroupMemberProfile, fetchAllPages
getGroupMemberProfile, fetchAllPages, getFriends, inviteGroupMembers
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
@@ -67,7 +67,7 @@ export const GROUP_MANAGEMENT_VIEWS = [
'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
'group-file-upload'
'group-file-upload', 'group-invite-friends'
];
// Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather
@@ -467,7 +467,12 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
</button>)}
<button type="button" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}>
{/* docs/images/内容沟通对话3-开启进群验证与拉人状态.png 里"你邀请XX、XX加入群聊"证明这条
"从好友里选人直接邀请"的路径是产品设计的一部分,POST /groups/{topic}/invitations
(inviteGroupMembers) 也早就是个真实可用的接口——原来这个格子只弹一句"请通过转发/分享
群二维码邀请成员",把这条路堵死了(二维码分享走的是另一条"申请入群待审批"的路,跟"邀请"
语义不同,不能互相替代)。 */}
<button type="button" onClick={() => navigate('group-invite-friends', topic)}>
<span className="group-member-tile-action"><Icon>add</Icon></span><small>添加</small>
</button>
</div>
@@ -644,6 +649,7 @@ function Governance({topic, myRole, tinode, onToast}) {
// and returns, so this is one of the highest-traffic pages in the file.
const [gov, setGov] = React.useState(() => (topic && readCachedData('group-governance:' + topic)) || null);
const [dissolving, setDissolving] = React.useState(false);
const [confirmingDissolve, setConfirmingDissolve] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
@@ -659,18 +665,27 @@ function Governance({topic, myRole, tinode, onToast}) {
updateGroupGovernance(topic, {[key]: value, version: gov ? gov.version : 0}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
.catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
// 原来是裸 window.confirm()——本工程已经在"退出登录"那次修复里明确认定这类原生弹窗和
// 其余确认弹层不一致(personal-settings-view.jsx 的 LogoutConfirm/ConfirmSheet 那条注释:
// "a native confirm() is also environment-dependent: some embedded WebViews/kiosk contexts
// disable synchronous dialogs outright...silently preventing [the action] with no error
// shown")。解散群组同样不可逆、后果同样重大,却是本文件里唯一还在用 window.confirm() 的
// 危险操作——本文件自己在 OwnerSelect 里已经有现成的同款弹层视觉语言(.group-sheet-backdrop
// /.group-confirm-sheet),改成两段式状态复用它,不再依赖浏览器原生对话框。
const requestDissolve = () => {
// 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;
setConfirmingDissolve(true);
};
const dissolve = () => {
const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return;
if (!t) { setConfirmingDissolve(false); return; }
setDissolving(true);
t.delTopic(true).then(() => { onToast('群聊已解散'); navigate('group-list'); })
.catch(err => onToast(err.message || '解散失败')).finally(() => setDissolving(false));
.catch(err => { onToast(err.message || '解散失败'); setConfirmingDissolve(false); }).finally(() => setDissolving(false));
};
if (!gov) return <div className="group-page"><Header title="群组管理" back="group-settings" backTopic={topic} /><main className="group-governance"><p className="group-inline-empty">{loadError ? '群组管理加载失败' : '加载中…'}</p></main></div>;
return <div className="group-page">
@@ -689,8 +704,14 @@ function Governance({topic, myRole, 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 || myRole !== 'owner'} onClick={dissolve}>解散群组</button>
<button type="button" className="group-danger" disabled={dissolving || myRole !== 'owner'} onClick={requestDissolve}>解散群组</button>
</main>
{confirmingDissolve ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>解散群组</h2>
<p>解散后群聊将无法恢复,确定要解散该群聊吗?</p>
<button type="button" className="danger" disabled={dissolving} onClick={dissolve}>{dissolving ? '处理中…' : '确定'}</button>
<button type="button" disabled={dissolving} onClick={() => setConfirmingDissolve(false)}>取消</button>
</section></div> : null}
</div>;
}
@@ -848,6 +869,72 @@ function MemberPicker({type, topic, onToast}) {
</div>;
}
// docs/images/内容沟通对话3-开启进群验证与拉人状态.png:"你邀请时间旅行者、李惠惠加入群聊" 这条系统
//事件证明产品设计里确实有一条"从我的好友里挑人直接邀请进群"的路径,且 `POST /groups/{topic}/invitations`
// (im-api.js 的 inviteGroupMembers) 早就封装好、服务端也确认真实可用——但全仓库检索不到任何调用点,
// GroupSettings 成员格的"添加"格子原来只弹一句"请通过转发/分享群二维码邀请成员",把这条本该走的路
// 堵死成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,语义并不一样,不能算是同一功能的替代)。
// 复用 MemberPicker 同款字母分组多选布局,数据源换成好友列表并按当前群成员名单过滤掉已在群里的人。
function InviteFriends({topic, onToast}) {
const [friends, setFriends] = React.useState(null);
const [memberIds, setMemberIds] = React.useState(null);
const [selected, setSelected] = React.useState([]);
const [query, setQuery] = React.useState('');
const [toast, setToast] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
fetchAllPages(cursor => getFriends('', cursor)).then(setFriends).catch(() => { onToast('加载失败'); setLoadError(true); });
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => setMemberIds(new Set(items.map(m => m.user_id))))
// 成员表拉取失败不应该拦掉整个邀请流程——退化成"不过滤",最坏情况后端自己会在
// 响应里把已在群里的人标成 already_member(治理开关那份文档已经实测过这个字段)。
.catch(() => setMemberIds(new Set()));
}, [topic]);
const eligible = (friends || []).filter(f => !memberIds || !memberIds.has(f.user_id));
const visible = eligible.filter(m => (m.remark || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const groups = groupBy(visible, m => alphaInitial(m.remark || m.display_name));
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => {
if (!selected.length || busy) return;
setBusy(true);
inviteGroupMembers(topic, selected).then(result => {
const items = (result && result.items) || [];
// 已实测过的两种真实 status:新邀请是 "pending"(对方会在跟我的会话里收到"立即加入"卡片),
// 治理开关关闭邀请权限那份文档里实测过 "already_member"(对方已经在群里,直接被服务端忽略)。
// 未识别的 status 一律计入"失败",宁可少报成功也不要谎报。
const already = items.filter(it => it.status === 'already_member').length;
const sent = items.filter(it => it.status === 'pending').length;
const failed = Math.max(selected.length - sent - already, 0);
const parts = [];
if (sent) parts.push(`已发送 ${sent} 条邀请`);
if (already) parts.push(`${already} 人已在群内`);
if (failed) parts.push(`${failed} 人邀请失败`);
setToast(parts.join(',') || '邀请已发送');
window.setTimeout(() => navigate('group-settings', topic), 1500);
}).catch(err => onToast(err.message || '邀请失败')).finally(() => setBusy(false));
};
return <div className="group-page">
<Header title="邀请好友" back="group-settings" backTopic={topic}
right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} />
<Search value={query} onChange={setQuery} placeholder="搜索好友" />
<main className="group-member-picker">
{friends === null || memberIds === null ? <p className="group-inline-empty">{loadError ? '好友列表加载失败' : '加载中…'}</p> :
groups.length === 0 ? <p className="group-inline-empty">{friends.length ? '好友都已在群里了' : '暂无可邀请的好友'}</p> :
groups.map(([letter, list]) => <React.Fragment key={letter}>
<div className="group-section-title">{letter}</div>
{list.map(item => <button type="button" key={item.user_id} onClick={() => toggle(item.user_id)}>
<Avatar item={item} icon="person" /><span><strong>{item.remark || item.display_name}</strong></span>
<i className={'group-check' + (selected.includes(item.user_id) ? ' checked' : '')}>
{selected.includes(item.user_id) ? <Icon>check</Icon> : null}
</i>
</button>)}
</React.Fragment>)}
</main>
<CenterToast message={toast} onDone={() => setToast('')} />
</div>;
}
function Muted({topic, onToast}) {
const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);
// groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --
@@ -967,6 +1054,21 @@ 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'];
// POST /groups/{topic}/files 的 file_type 字段是客户端在登记时自己声明的(addGroupFile 的
// im-api.js 签名早就留了这个参数),不是服务端拿 mime_type 反推的——实测:即使 mime_type 正确
// 存成 "image/png",只要请求体没带 file_type,服务端就把它落成字面量 "file"(不是留空)。这里
// 的 onFile 上传流程原来完全没传这个字段,导致真实是图片/视频/语音的群文件在列表里全部显示成
// 灰色通用文件图标,跟 FILE_TYPE_META 已经准备好的 image/video/audio/document 四种彩色图标从未
// 对应起来过。跟 content-library-view.jsx 的 fileTypeFromMime 用的是同一套判断(mime 前缀优先,
// 帶附件扩展名兜底文档类型),保持两处"什么算图片/视频/文档"的口径一致。
function fileTypeFromMime(mime, name) {
const prefix = (mime || '').split('/')[0];
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
const ext = (/\.([a-zA-Z0-9]+)$/.exec(name || '') || [])[1];
if (mime === 'application/pdf' || DOCUMENT_EXT.includes((ext || '').toLowerCase())) return 'document';
return 'file';
}
function GroupFiles({topic, tinode, onToast}) {
const [queryInput, setQueryInput] = React.useState('');
@@ -1016,7 +1118,8 @@ function GroupFiles({topic, tinode, onToast}) {
if (!file || !tinode) return;
setUploading({name: file.name, progress: 0});
uploadLargeFile(tinode, file, frac => setUploading(u => u ? {...u, progress: Math.round(frac * 100)} : u))
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size}))
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size,
fileType: fileTypeFromMime(file.type, file.name)}))
.then(() => { setUploading(null); load(); })
.catch(err => { setUploading(null); onToast(err.message || '上传失败'); });
};
@@ -1112,12 +1215,21 @@ function MyNicknameEditor({topic, onToast}) {
const [value, setValue] = React.useState('');
// getGroupMembers' list items never include group_nickname (confirmed live) -- only
// GET .../members/me/profile actually returns it, same fix as GroupProfile's preview row above.
// 同一个 GET 实测确认真的带 version 字段(不是空字符串/undefined),但这里原来保存时
// 一直硬编码 version:0——跟本文件其余"编辑单个字段+乐观锁"的写法(GroupNameEditor/
// GroupDescriptionEditor 用 getGroup 拿到的真实 version,Governance/OwnerSelect 的
// 注释更是明确写了"version<=0 时服务端会整个跳过乐观锁 WHERE 子句,两次并发写入会
// 静默覆盖")不一致。实测验证过这里同样如此:明明真实 version 是 1,PUT 时传 0 依然
// 200 成功且服务端自己把 version 推到了 2——多标签页/多端几乎同时改自己在本群的昵称
// 时,后写入的会静默覆盖先写入的,没有任何冲突信号。跟其余几处一样,把已经拿到手的
// 真实 version 存下来,保存时原样带上。
const [version, setVersion] = React.useState(0);
React.useEffect(() => {
if (!topic) return;
getMyGroupProfile(topic).then(data => setValue(data.nickname || '')).catch(() => {});
getMyGroupProfile(topic).then(data => { setValue(data.nickname || ''); setVersion(data.version || 0); }).catch(() => {});
}, [topic]);
const save = () => {
updateMyGroupProfile(topic, value.trim(), 0).then(() => {
updateMyGroupProfile(topic, value.trim(), version).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
@@ -1168,6 +1280,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
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} />;
else if (view === 'group-invite-friends') content = <InviteFriends topic={topic} onToast={setToast} />;
else if (view === 'group-muted') content = <Muted topic={topic} onToast={setToast} />;
else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = <OwnerSelect topic={topic} onToast={setToast} />;
else content = <GroupFiles topic={topic} tinode={tinode} onToast={setToast} />;
-95
View File
@@ -1,95 +0,0 @@
// Login form.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import CheckBox from '../widgets/checkbox.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
export default class LoginView extends React.Component {
constructor(props) {
super(props);
this.state = {
login: props.login,
password: '',
hostName: props.serverAddress,
saveToken: props.persist
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
handleToggleSaveToken() {
this.props.onPersistenceChange(!this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
}
render() {
let submitClasses = 'primary';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form id="login-form" onSubmit={this.handleSubmit}>
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Placeholer for username/login">
{
(login_prompt) => <input type="text" id="inputLogin"
placeholder={login_prompt}
autoComplete="username"
autoCorrect="off"
autoCapitalize="none"
value={this.state.login}
onChange={this.handleLoginChange}
required autoFocus />
}
</FormattedMessage>
<FormattedMessage id="password_prompt" defaultMessage="Password"
description="Placeholder/prompt for entering password">
{
(password_prompt) => <VisiblePassword type="password" id="inputPassword"
placeholder={password_prompt}
autoComplete="current-password"
value={this.state.password}
onChange={this.handlePasswordChange}
required={true} />
}
</FormattedMessage>
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<label htmlFor="save-token">&nbsp;
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
description="Label for a checkbox" />
</label>
<a href="#reset">
<FormattedMessage id="forgot_password_link" defaultMessage="Forgot password?"
description="Link to Reset password form" />
</a>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">
<FormattedMessage id="button_sign_in" defaultMessage="Sign in"
description="Button [Sign In]" />
</button>
</div>
</form>
);
}
};
+25
View File
@@ -106,6 +106,14 @@ function draftyPreview(content) {
const label = {IM: '[图片]', VD: '[视频]', AU: '[语音]', EX: '[文件]', TC: '[名片]', VC: '[通话]'}[content.ent[0].tp];
if (label) return label;
}
// 服务端的 system_notice/system_action(好友通过、群成员加入、群邀请卡片等,见 chat-message.jsx
// 对 x-im-type 的处理)以及合并转发消息都不是 Drafty,也没有 `txt` 字段,真正的可读文案在
// `text`(system_*)或 `comment`/`title`(forward_bundle)——原来这里只认 `txt`,这三类消息在
// 会话列表的预览行一律回退成没有信息量的"[消息]",即使内容本身早就是一句完整人话
// (比如"邀请你加入群聊。"),聊天页里点开看到的和列表预览完全对不上。
if (typeof content.text === 'string' && content.text) return content.text;
if (typeof content.comment === 'string' && content.comment) return content.comment;
if (typeof content.title === 'string' && content.title) return '[聊天记录] ' + content.title;
return content.txt || '[消息]';
}
@@ -412,6 +420,12 @@ function MessageActions() {
const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
// 实测 asn494180 账号的真实系统消息列表里出现过原样展示的 "group.announcement.updated"——
// db/mysql/tenant_groups.go 里 insertGroupEventTx 一共写了 9 种 group.* 事件码
// (announcement.updated/created/join_request.created/member.joined/member.left/
// member.updated/mute.updated/owner.transferred/role.updated/settings.updated),这张表原来
// 漏收了公告更新这一种,是 9 种里唯一没有查表命中、直接把机器码露给用户看的。
'group.announcement.updated': '群公告有更新',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
@@ -419,6 +433,17 @@ const NOTIFICATION_TEXT = {
'group.owner.transferred': '群主已转让',
'group.role.updated': '群成员角色有变更',
'group.settings.updated': '群设置有更新',
// 上面那 9 种都是 insertGroupEventTx 的静态字符串调用,唯独审批入群申请这一条不是:
// TenantGroupJoinRequestResolve(tenant_groups.go:642)拼的是
// "group.join_request."+status,status 是 "accepted" 或 "rejected"(server/store/types/groups.go
// 的 GroupJoinRequestStatusAccepted/Rejected),实际落进 summary 的是
// "group.join_request.accepted"/"group.join_request.rejected" 这两个完整机器码,跟本已收录的
// "group.join_request.created"(申请提交时那条)是三个不同的 key。当初核对 9 种静态事件码时
// 这条动态拼接的漏了,管理员在群管理页批准/拒绝入群申请后,申请人这边系统消息列表同样会原样
// 展示机器码——跟 group.announcement.updated 是完全同一类问题,只是触发路径不同(未在真实
// 环境里等到一条这样的通知,是读 im_be 源码逐个比对 insertGroupEventTx 调用点发现的)。
'group.join_request.accepted': '你的入群申请已通过',
'group.join_request.rejected': '你的入群申请被拒绝',
accepted: '对方已通过你的好友申请',
rejected: '对方拒绝了你的好友申请',
// 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量
+214 -12
View File
@@ -3,7 +3,24 @@
import React, { Suspense } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty, Tinode, TheCard, LOCAL_SEQID } from 'tinode-sdk';
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
// webpack.config.js maps the 'tinode-sdk' import to the global `tinode` UMD bundle, which only
// re-exports {AccessMode, Drafty, TheCard, Tinode} (see umd/tinode.dev.js's own top-level export
// list) -- LOCAL_SEQID was never one of them, despite tinode-sdk's ES module genuinely exporting
// it (confirmed directly against umd/tinode.dev.js, which still defines it internally as
// `const LOCAL_SEQID = 0xFFFFFFF;` and uses it in several of its own topic methods -- it's just
// never attached to the UMD bundle's exported global object). Importing it by name here silently
// resolved to `undefined` instead of a build error (an externals mapping can't tell "not on this
// object" from "doesn't exist"), and every `seq < LOCAL_SEQID` below was therefore
// `seq < undefined`, which JS always evaluates to false -- confirmed live: "撤回"
// (menu_item_recall)never appeared in the long-press
// menu for ANY message, own or admin, however fresh, because the seq-range check that's supposed
// to just filter out not-yet-confirmed local placeholder messages was unconditionally rejecting
// every real, server-confirmed message too. Hardcoding the same value the SDK itself uses (rather
// than trying to change what webpack's externals map exposes, which every other page importing
// from 'tinode-sdk' already depends on staying exactly as-is) is the narrowest fix.
const LOCAL_SEQID = 0xFFFFFFF;
const CallPanel = React.lazy(_ => import('../widgets/call-panel.jsx'));
import ChatMessage from '../widgets/chat-message.jsx';
@@ -29,9 +46,9 @@ import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDI
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getFavorites, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
updateTopicDraft } from '../lib/im-api.js';
import { acceptGroupInvitation, checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap,
getConversationContext, getFavorites, getFriendRequests, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement,
getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -205,6 +222,7 @@ class MessagesView extends React.Component {
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
this.handleAcceptGroupInvitation = this.handleAcceptGroupInvitation.bind(this);
this.handleBatchDelete = this.handleBatchDelete.bind(this);
this.handleBatchFavorite = this.handleBatchFavorite.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
@@ -477,6 +495,7 @@ class MessagesView extends React.Component {
selectedPin: 0,
subsVersion: 0,
relationshipContext: null,
pendingRequestMessage: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
@@ -505,6 +524,7 @@ class MessagesView extends React.Component {
dragging: false,
selectedPin: 0,
relationshipContext: null,
pendingRequestMessage: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
@@ -664,7 +684,7 @@ class MessagesView extends React.Component {
}
subscribe(topic) {
if (topic.isSubscribed() || !this.props.ready) {
if (!this.props.ready) {
return;
}
@@ -675,7 +695,24 @@ class MessagesView extends React.Component {
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux();
if (this.state.isReader || newTopic) {
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
//
// withData(undefined, undefined, MESSAGES_PAGE) instead of the seemingly-more-natural
// withLaterData(MESSAGES_PAGE): the latter asks the server for "whatever is newer than
// topic._maxSeq", which is only correct when _maxSeq reflects a real, contiguous, locally-cached
// history. But tinode-web.jsx's prefetchLatestMessagePreviews() (conversation list "last
// message" preview) may have already called topic.subscribe(startMetaQuery().withLaterData(1))
// for this exact topic the moment the chat list rendered -- often *before* this view ever mounts,
// e.g. the very first time a brand-new conversation is opened in a fresh session -- which sets
// _maxSeq to that one prefetched message's seq without caching anything before it. From that
// point on withLaterData(MESSAGES_PAGE) asks for "later than the one message I already have" and
// gets back nothing: confirmed live, a brand-new 4-message P2P conversation opened this way
// rendered only the single latest message, silently missing the rest of the history.
// withData(undefined, undefined, limit) has no such since-cursor -- it always asks for "the
// latest `limit` messages" regardless of what the client thinks it already has, which is
// correct both in this poisoned-prefetch case and in the plain fresh-topic case (there
// topic._maxSeq is 0, so withLaterData(limit) already degrades to the exact same
// since:undefined query -- this is a strict fix, not a behavior change for the common path).
getQuery = getQuery.withData(undefined, undefined, MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
@@ -684,7 +721,7 @@ class MessagesView extends React.Component {
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
const doSubscribe = () => topic.subscribe(getQuery.build(), setQuery)
.then(ctrl => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
@@ -725,6 +762,24 @@ class MessagesView extends React.Component {
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
if (topic.isSubscribed()) {
// Same prefetch race as the comment on the data query above: the topic may already be
// "attached" server-side from prefetchLatestMessagePreviews' single-message subscribe, before
// this view ever asked for anything. The SDK's own Topic.subscribe() unconditionally
// short-circuits to `Promise.resolve(this)` once `_attached` is true (tinode.dev.js)
// *without even looking at the getParams passed in* -- calling topic.subscribe(getQuery...)
// directly in that state would silently no-op and this view would never receive the real
// history/pinned-messages/peer read-receipts it just asked for. leave(false) clears _attached
// (both client-side via _resetSub() and server-side, since it's a real round trip) without
// discarding already-cached messages (that only happens for leave(true), i.e. unsubscribe) --
// once it resolves, the follow-up doSubscribe() is guaranteed to be a genuine, un-shortcut
// subscribe that gets the server to actually answer getQuery instead of silently dropping it.
topic.leave(false).catch(_ => { /* best-effort; doSubscribe() below still needs to run */ }).then(doSubscribe);
return;
}
doSubscribe();
}
leave(oldTopicName) {
@@ -1198,12 +1253,48 @@ class MessagesView extends React.Component {
}
const topicName = topic.name;
getConversationContext(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({relationshipContext: data});
if (this.state.topic != topicName) {
return;
}
this.setState({relationshipContext: data});
this.loadPendingRequestMessage(topicName, data);
}).catch(_ => {});
}
// 个人对话框(对方的状态).png / (我的状态).png:pending_inbound/pending_outbound 关系下,聊天
// 页顶部第一条应该是发起好友申请时留的验证留言原文,以真实消息气泡的样式渲染(对方发的居左、
// 自己发的居右),下面再跟现有的 #relationship-status-note 横幅。这条留言从来不是一条真实的
// Tinode 消息(没有走 topic.publish(),只存在 im_friend_requests 表的 message 字段里),所以
// 不会自然出现在 messageNodes 里——relationshipContextPayload 已经带了 request_id,用它去
// GET /friend-requests 换真实留言文本;找不到(比如留言本身是空的)就回退成参考图里的默认文案。
loadPendingRequestMessage(topicName, ctx) {
const relationship = ctx && ctx.relationship;
if (relationship != 'pending_inbound' && relationship != 'pending_outbound') {
this.setState({pendingRequestMessage: null});
return;
}
if (!ctx.request_id) {
this.setState({pendingRequestMessage: {text: '请求添加你为好友', response: relationship == 'pending_inbound', ts: null}});
return;
}
const direction = relationship == 'pending_inbound' ? 'inbound' : 'outbound';
getFriendRequests(direction, 'all').then(data => {
if (this.state.topic != topicName) {
return;
}
const match = (data.items || []).find(item => item.request_id == ctx.request_id);
this.setState({pendingRequestMessage: {
text: (match && match.message) || '请求添加你为好友',
response: relationship == 'pending_inbound',
ts: match ? new Date(match.created_at) : null
}});
}).catch(_ => {
if (this.state.topic == topicName) {
this.setState({pendingRequestMessage: {text: '请求添加你为好友', response: relationship == 'pending_inbound', ts: null}});
}
});
}
// Ch.6: role badges (docs/images/群标识.png) and the announcement/join-request banners at the top of a
// group chat (docs/images/群公告状态.png, 新邀请进群申请.png) all need data no P2P topic has -- fetch it
// once per group-topic-load, same best-effort pattern as loadRelationshipContext above. Every `topic`
@@ -1384,6 +1475,29 @@ class MessagesView extends React.Component {
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
}
// 6.3 加入群聊.png:被邀请人在跟邀请人的会话里收到一张"邀请你加入群聊……立即加入"的卡片。
// 后端 serveIMGroupInvitations 确实往这个 P2P 会话发了一条 x-im-type=system_action、
// template=group_invitation_created 的消息,content.actions 里带着真实的
// {type:"group_invitation_accept", label:"立即加入", target_id:<invitation_id>}——但
// chat-message.jsx 原来对所有 system_action/system_notice 消息一律只取 content.text 渲染成
// 纯文本,这个 action 数据被整体丢弃,卡片上不会出现任何按钮,被邀请人无法接受邀请。
// acceptGroupInvitation() 这个封装早就在 im-api.js 里,只是从未有调用点。
handleAcceptGroupInvitation(invitationId) {
return acceptGroupInvitation(invitationId).then(result => {
if (result && result.status == 'joined') {
this.showToast('已加入群聊');
} else if (result && result.status == 'pending_approval') {
this.showToast('申请已提交,等待管理员通过');
} else {
this.showToast('已处理该邀请');
}
return result;
}).catch(err => {
this.showToast(err.message || '加入群聊失败,请重试');
throw err;
});
}
showToast(text) {
this.setState({toast: text});
window.clearTimeout(this.toastTimer);
@@ -1501,7 +1615,6 @@ class MessagesView extends React.Component {
// We are forwarding a message.
msg = this.props.forwardMessage.msg;
head = this.props.forwardMessage.head;
this.handleCancelReply();
} else if (this.state.reply) {
if (this.state.reply.editing) {
if (msg == this.state.contentToEdit) {
@@ -1519,9 +1632,25 @@ class MessagesView extends React.Component {
msg = Drafty.parse(msg);
this.applyMentionResolution(msg);
}
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
// Both tinode.dev.js helpers below mutate their first argument in place and return that
// same reference (Drafty.sanitizeEntities: `return content` after patching content.ent[i]
// itself; Drafty.append: `first.txt += ...`, `first.fmt.push(...)`, then `return first`) --
// neither ever clones. Passing `this.state.reply.content` straight through used to hand this
// live React-state object to both, so every call here permanently welded whatever `msg` this
// particular attempt carried onto the quoted content the *reply itself* points to. Invisible
// on an ordinary send since handleCancelReply() clears state.reply right after, discarding the
// now-mutated copy along with it -- but the URL risk dialog above defers doSend (and this
// append) until *after* the check resolves, and cancelling it deliberately leaves state.reply
// alone so the reply survives (see doSend's own comment). Confirmed live: reply to a message,
// type a flagged link, send, click "取消发送", send the exact same text again, confirm through
// the dialog this time -- the composed message quoted the reply once but then repeated the
// typed text twice, because the first (cancelled) attempt had already appended it into
// state.reply.content, and the second attempt appended onto *that*. A deep clone here keeps
// both helpers' in-place mutations confined to a throwaway copy every time, however many times
// sendMessage runs for the same still-open reply.
const replyContentCopy = JSON.parse(JSON.stringify(this.state.reply.content));
msg = Drafty.append(Drafty.sanitizeEntities(replyContentCopy), msg);
}
this.handleCancelReply();
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但
// data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val =>
@@ -1560,6 +1689,19 @@ class MessagesView extends React.Component {
// the same backend version=0-skips-the-lock issue explained in handleComposerChange above.
const draftVersionAtSend = this.state.draftVersion || 0;
const doSend = () => {
// Was called eagerly above (right after building `msg`/`head` from `this.state.reply`/
// `this.props.forwardMessage`), synchronously and unconditionally -- including on a message
// that turns out to need the URL risk dialog below, which only resolves *after* this function
// already returned. That meant clicking send on a reply/edit containing a link made the
// "回复"/编辑 preview bar vanish immediately, before the user had even seen the risk prompt;
// clicking "取消发送" then left the reply/edit permanently abandoned -- the quoted message was
// gone from the composer with no way back except manually replying again, even though the user
// only meant to cancel sending the flagged text, not the reply itself. Moving the call here
// means it only fires at the point a send is actually going to happen: immediately for the
// common non-URL case (this function runs synchronously right away, same timing as before),
// or deferred until "继续发送" for a flagged one -- and never at all if the dialog is cancelled,
// so the reply/edit state and its preview bar correctly survive a cancelled risk prompt.
this.handleCancelReply();
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
@@ -1584,6 +1726,23 @@ class MessagesView extends React.Component {
const RISK_RANK = {block: 2, warn: 1, allow: 0};
const urls = Array.from(new Set(urlMatches));
return Promise.all(urls.map(url => checkURL(url, 'message_compose'))).then(results => {
// The user may have already switched to a different conversation while checkURL was
// still in flight. getDerivedStateFromProps only protects against an ALREADY-shown
// dialog surviving a later topic switch (it nulls riskDialog when nextProps.topic !=
// prevState.topic) -- it can't protect against this .then() running AFTER that switch,
// which would otherwise setState({riskDialog:{...}}) fresh right here, popping the risk
// dialog up while a completely different conversation is now open. doSend() still calls
// this.props.sendMessage using whatever topic is current AT CONFIRM TIME (see doSend's
// own comment above), not the one this message was actually typed in, so confirming that
// dialog would silently deliver this message into the wrong conversation. Confirmed live:
// typed a flagged URL in topic A, switched to topic B before checkURL resolved, and the
// resulting dialog rendered while viewing topic B -- confirming it delivered topic A's
// message into topic B instead. If the topic has moved on, treat this message the same
// as if it were abandoned -- no dialog, no send -- the same "lost, but not hung forever"
// outcome already accepted just below for two flagged messages sent in quick succession.
if (this.state.topic !== topicForDraft) {
return false;
}
const result = results.reduce((worst, r) => (RISK_RANK[r.decision] || 0) > (RISK_RANK[worst.decision] || 0) ? r : worst);
if (result.decision === 'allow') {
return doSend();
@@ -2315,6 +2474,16 @@ class MessagesView extends React.Component {
replyToSeq = null;
}
// group_invitation_created is the one system_action template with a real, backend-honored
// action button (see chat-message.jsx's renderGroupInvitationCard) -- it still needs the
// full ChatMessage bubble treatment. Every other system_action/system_notice template is
// purely informational and belongs in the plain centered style below.
const systemActions = msg.content && typeof msg.content == 'object' && Array.isArray(msg.content.actions) ?
msg.content.actions : null;
const isActionableSystemMessage = systemActions && systemActions.some(a => a && a.type == 'group_invitation_accept');
const isPlainSystemMessage = msg.head && (msg.head['x-im-type'] == 'system_notice' || msg.head['x-im-type'] == 'system_action') &&
!isActionableSystemMessage && msg.content && typeof msg.content == 'object' && typeof msg.content.text == 'string';
if (msg.hi) {
// Deleted message.
messageNodes.push(
@@ -2323,6 +2492,22 @@ class MessagesView extends React.Component {
deletedText={this.props.intl.formatMessage(messages.message_deleted)}
key={msg.seq} />
);
} else if (isPlainSystemMessage) {
const thisDate = new Date(msg.ts);
if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) {
messageNodes.push(
<MetaMessage
date={relativeDateFormat(msg.ts)}
locale={this.props.intl.locale}
key={'date-' + msg.seq} />
);
prevDate = thisDate;
}
messageNodes.push(
<MetaMessage
systemText={msg.content.text}
key={msg.seq} />
);
} else {
const thisDate = new Date(msg.ts);
// This message was sent on a different date than the previous.
@@ -2360,6 +2545,7 @@ class MessagesView extends React.Component {
userIsAdmin={this.state.isAdmin}
relationship={this.state.relationshipContext ? this.state.relationshipContext.relationship : null}
onSendFriendVerification={this.handleSendFriendVerification}
onAcceptGroupInvitation={this.handleAcceptGroupInvitation}
selectMode={this.props.selectMode}
selected={this.props.selectedSeqs && this.props.selectedSeqs.includes(msg.seq)}
onToggleSelect={this.props.onToggleMessageSelect}
@@ -2438,6 +2624,22 @@ class MessagesView extends React.Component {
</button>
<div id="messages-panel" ref={this.handleScrollReference}>
<ul id="scroller" className={chatBoxClass}>
{this.state.pendingRequestMessage ?
<>
{this.state.pendingRequestMessage.ts ?
<MetaMessage date={relativeDateFormat(this.state.pendingRequestMessage.ts)}
locale={this.props.intl.locale} key="pending-request-date" /> : null}
<li className={'single ' + (this.state.pendingRequestMessage.response ? 'left' : 'right')}
key="pending-request-message">
<div>
<div className="bubble tip">
<div className="content-meta">
<div className="message-content">{this.state.pendingRequestMessage.text}</div>
</div>
</div>
</div>
</li>
</> : null}
{messageNodes}
</ul>
</div>
-231
View File
@@ -1,231 +0,0 @@
// A password reset form.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import PhoneEdit from '../widgets/phone-edit.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import HashNavigation from '../lib/navigation.js';
const messages = defineMessages({
password_reset_email_sent: {
id: 'password_reset_email_sent',
defaultMessage: 'An email with security code has been sent.',
description: 'Notification that the email with password reset instructions has been sent'
},
password_reset_sms_sent: {
id: 'password_reset_sms_sent',
defaultMessage: 'A text message with security code has been sent.',
description: 'Notification that the SMS with password reset instructions has been sent'
}
});
class PasswordResetView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
tel: '',
email: '',
password: '',
sent: false,
haveCode: false,
code: ''
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleCodeChange = this.handleCodeChange.bind(this);
this.handleShowCodeField = this.handleShowCodeField.bind(this);
// Connection will trigger change by changing the this.props.serverVersion.
props.tinode.connect()
.catch(err => {
this.props.onError(err.message, 'err');
});
}
componentDidMount() {
const parsed = HashNavigation.parseUrlHash(window.location.hash);
const newState = {
token: parsed.params.token,
scheme: parsed.params.scheme,
code: parsed.params.code || ''
}
this.savedCred = parsed.params.cred;
if (this.props.reqCredMethod && parsed.params.cred) {
newState[this.props.reqCredMethod] = this.savedCred;
}
this.setState(newState);
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.reqCredMethod != this.props.reqCredMethod && this.props.reqCredMethod) {
this.setState({[this.props.reqCredMethod]: this.savedCred || ''});
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
// Reset using token.
this.props.onReset(this.state.password.trim(), {
scheme: 'token',
secret: this.state.token
});
} else if (this.state.code && this.props.reqCredMethod) {
// Reset using security code.
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
const cred = this.props.reqCredMethod == 'email' ? this.state.email.trim() : this.state.tel.trim();
this.props.onReset(this.state.password.trim(), {
scheme: 'code',
secret: btoa(`${this.state.code}:${this.props.reqCredMethod}:${cred}`)
});
} else {
// Send email or SMS with the secret code.
const email = this.state.email.trim();
const tel = this.state.tel.trim();
this.setState({email: email, tel: tel});
this.props.onRequest(this.props.reqCredMethod, email || tel)
.then(_ => {
this.setState({sent: true});
const msg = this.props.reqCredMethod == 'email' ? messages.password_reset_email_sent :
this.props.reqCredMethod == 'tel' ? messages.password_reset_sms_sent : null;
if (msg) {
this.props.onError(this.props.intl.formatMessage(msg), 'info');
}
});
}
}
handleEmailChange(e) {
this.setState({email: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
handleCodeChange(e) {
this.setState({code: e.target.value.replace(/[^\d]/g, '')});
}
handleShowCodeField(e) {
e.preventDefault();
this.setState({haveCode: true});
}
render() {
const showCredentialInput = !((this.state.token || (this.state.code && this.props.reqCredMethod)) && this.state.scheme);
const showPasswordInput = !showCredentialInput || this.state.haveCode || this.state.sent;
const passwordInput = (<>
<label className="small gray" htmlFor="new-password">
<FormattedMessage id="label_new_password"
defaultMessage="New password"
description="Label for entering a new password" />
</label>
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
description="Placeholder for entering new password">{
(placeholder) => <VisiblePassword
id="new-password"
placeholder={placeholder}
autoComplete="new-password"
value={this.state.password}
required={true} autoFocus={true}
onChange={this.handlePasswordChange} />
}</FormattedMessage>
</>);
const emailInput = (<>
{this.state.haveCode ?
null :
<label htmlFor="inputEmail">
<FormattedMessage id="label_reset_password"
defaultMessage="Send a password reset email"
description="Label for password reset field" />
</label>
}
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
description="Placeholder for entering email">{
(placeholder) => <input type="email" id="inputEmail"
placeholder={placeholder}
autoComplete="email"
value={this.state.email}
onChange={this.handleEmailChange}
required autoFocus />
}</FormattedMessage>
</>);
const phoneInput = (<>
{this.state.haveCode ?
null :
<div><label>
<FormattedMessage id="label_reset_password_tel"
defaultMessage="Send SMS to reset password"
description="Label for password reset field by phone" />
</label></div>
}
<div>
<label className="small gray">
<FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
description="Prompt for entering a mobile phone number" />
</label>
</div>
<div className="panel-form-row">
<PhoneEdit
autoFocus={true}
onShowCountrySelector={this.props.onShowCountrySelector}
onSubmit={number => this.setState({tel: number})} />
</div>
</>);
const codeInput = (<>
<div>
<label className="small gray" htmlFor="enter-confirmation-code">
<FormattedMessage id="enter_confirmation_code_prompt"
defaultMessage="Confirmation code"
description="Request to enter confirmation code" />
</label>
</div>
<div>
<FormattedMessage id="numeric_confirmation_code_prompt"
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
(numbers_only) => <input type="text" id="enter-confirmation-code"
placeholder={numbers_only} maxLength={10}
value={this.state.code} onChange={this.handleCodeChange} required />
}</FormattedMessage>
</div>
</>);
const credentialInput = this.props.reqCredMethod == 'email' ?
emailInput : this.props.reqCredMethod == 'tel' ? phoneInput : null;
return (
<form id="password-reset-form" onSubmit={this.handleSubmit}>
{!this.state.sent && showCredentialInput ? credentialInput : null}
{this.state.haveCode || this.state.sent ? codeInput : null}
{showPasswordInput ? passwordInput : null}
<div className="dialog-buttons">
{this.state.haveCode || this.state.sent || this.state.token || this.state.code ? null :
<a href="#" onClick={this.handleShowCodeField} style={{marginRight: 'auto'}}>
<FormattedMessage id="password_i_have_code" defaultMessage="I have code"
description="Call to open field to enter password reset code" />
</a>
}
<button className="primary" type="submit">{
showPasswordInput ?
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
:
<FormattedMessage id="button_send_request" defaultMessage="Send request"
description="Button [Send request]" />
}</button>
</div>
</form>
);
}
}
export default injectIntl(PasswordResetView);
+29 -9
View File
@@ -367,20 +367,40 @@ function CacheClear({onToast, onClose}) {
const confirm = async () => {
setBusy(true);
try {
// navigator.storage/caches can be entirely absent (private browsing in some browsers, older
// WebViews) rather than just rejecting -- referencing a missing global throws synchronously,
// which a bare .catch() on the call below can't catch (it never gets that far), so without
// this check the whole confirm() throws, `done` never becomes true, and the "已完成" state is
// never reached even though the button correctly stops spinning via `finally`.
const hasStorageEstimate = typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate;
// Was a before/after navigator.storage.estimate() diff around the caches.delete() call --
// measured live (see this round's regression notes): estimate() does NOT reflect a deletion
// synchronously, Chrome keeps reporting the pre-delete `usageDetails.caches` figure for well
// over 500ms after `caches.keys()` already comes back empty. Every real run of this button
// therefore computed `before - after` from two numbers that were still identical, landing on
// "已释放约 0.0 MB" even when megabytes of real thumbnails/temp files were actually deleted --
// confirmed by seeding a 2MB dummy Cache Storage entry and watching this path report 0.0 MB
// freed for it. Summing each entry's real blob size before deleting it sidesteps the OS-level
// storage accounting lag entirely -- the number shown is exactly what this run deleted, not an
// estimate that may or may not have caught up yet.
// caches can be entirely absent (private browsing in some browsers, older WebViews) rather than
// just rejecting -- referencing a missing global throws synchronously, which a bare .catch() on
// the call below can't catch (it never gets that far), so without this check the whole
// confirm() throws, `done` never becomes true, and the "已完成" state is never reached even
// though the button correctly stops spinning via `finally`.
const hasCaches = typeof caches !== 'undefined';
const before = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
let freedBytes = 0;
if (hasCaches) {
const keys = await caches.keys();
for (const key of keys) {
const cache = await caches.open(key);
const requests = await cache.keys();
for (const request of requests) {
const response = await cache.match(request);
if (!response) continue;
// clone() -- match() gives back the one real (non-clonable-twice) body for this entry;
// reading .blob() off it directly would leave nothing for a concurrent reader of the
// same cache, however unlikely that is here.
freedBytes += (await response.clone().blob().catch(() => null))?.size || 0;
}
}
await Promise.all(keys.map(k => caches.delete(k)));
}
const after = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
setFreed(Math.max(0, before - after));
setFreed(freedBytes);
setDone(true);
} catch (err) {
onToast(err.message || '清理失败,请重试');
+3 -38
View File
@@ -19,14 +19,11 @@ import SecuritySupportView, {SECURITY_SUPPORT_VIEWS} from './security-support-vi
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
import ContactsView from './contacts-view.jsx';
const CreateAccountView = React.lazy(_ => import('./create-account-view.jsx'));
import AccGeneralView from './acc-general-view.jsx';
import AccNotificationsView from './acc-notifications-view.jsx';
const AccSecurityView = React.lazy(_ => import('./acc-security-view.jsx'));
import AccSupportView from './acc-support-view.jsx';
import LoginView from './login-view.jsx';
import NewTopicView from './new-topic-view.jsx';
const PasswordResetView = React.lazy(_ => import('./password-reset-view.jsx'));
import SettingsView from './settings-view.jsx';
import ValidationView from './validation-view.jsx';
const WallpapersView = React.lazy(_ => import('./wallpapers.jsx'));
@@ -196,7 +193,8 @@ class SidepanelView extends React.PureComponent {
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
return <div id="sidepanel" className="contact-management-panel">
<ContactManagementView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} onOpenScanner={this.props.onOpenScanner} key={view} />
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} onCreateTopic={this.props.onCreateTopic}
onOpenScanner={this.props.onOpenScanner} key={view} />
</div>;
}
@@ -281,27 +279,7 @@ class SidepanelView extends React.PureComponent {
<LoadSpinner show={this.props.loadSpinnerVisible} />
{view === 'login' ?
<LoginView
login={this.props.login}
disabled={this.props.loginDisabled}
persist={this.props.persist}
onLogin={this.props.onLoginRequest}
onPersistenceChange={this.props.onPersistenceChange} /> :
view === 'register' ?
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
<CreateAccountView
tinode={this.props.tinode}
reqCredMethod={this.props.reqCredMethod}
onShowCountrySelector={this.props.onShowCountrySelector}
onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel}
onError={this.props.onError} />
</Suspense> :
view === 'settings' ?
{view === 'settings' ?
<SettingsView
transport={this.props.transport}
serverAddress={this.props.serverAddress}
@@ -415,19 +393,6 @@ class SidepanelView extends React.PureComponent {
credToken={this.props.credToken}
onSubmit={this.props.onValidateCredentials}
onCancel={this.props.onCancel} /> :
view === 'reset' ?
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
<PasswordResetView
tinode={this.props.tinode}
reqCredMethod={this.props.reqCredMethod}
onShowCountrySelector={this.props.onShowCountrySelector}
onRequest={this.props.onPasswordResetRequest}
onReset={this.props.onResetPassword}
onCancel={this.props.onCancel}
onError={this.props.onError} />
</Suspense> :
null}
</div>
);
+13 -1
View File
@@ -127,6 +127,7 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c
'group-my-nickname','group-members','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'group-invite-friends',
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading',
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
@@ -800,7 +801,18 @@ class TinodeWeb extends React.Component {
// Topic for MessagesView selector.
let topicName = hash.path[1] || null;
if (topicName != this.state.topicSelected) {
if (!Tinode.topicType(topicName)) {
// A syntactically valid topic name is not enough on its own: mobilePanel='topic-view' only
// ever pairs correctly with MessagesView actually being allowed to render, which requires
// !chapterOneActive (i.e. this.tinode.isAuthenticated()) -- see tinode-web.jsx's render().
// Without this check, opening (or being redirected/deep-linked to) a #/<topic> URL while
// logged out set mobilePanel to 'topic-view' anyway; on mobile that made *both* the
// SidepanelView gate (`mobilePanel=='sidepanel'`) and the MessagesView gate
// (`!chapterOneActive`) false at once, so neither rendered -- a permanently blank white
// screen (app-container empty, no thrown error) that a hash change alone couldn't recover
// from, only a full reload. Reproduced live: logging out, then setting location.hash to a
// real group's `#/<topic>` URL. Falling back to the same "invalid topic" branch already used
// for a malformed name reuses a path already proven safe with topicName=null.
if (!Tinode.topicType(topicName) || !this.tinode.isAuthenticated()) {
// Clear invalid topic name and hide messages view on mobile.
topicName = null;
newState.mobilePanel = 'sidepanel';
+25 -5
View File
@@ -196,9 +196,20 @@ function ImgFromFile({fileId, className}) {
const LEVEL_NUMERALS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const levelLabel = n => LEVEL_NUMERALS[n - 1] || String(n);
function DeptRow({dept, depth, expanded, onToggle}) {
// isMe check shared with PersonRow below -- organization member/participant ids across this
// chapter's endpoints aren't consistently confirmed to carry the 'usr' prefix (see resolveUserName's
// comment), so the same "try bare, then usr-prefixed" comparison is duplicated at both call sites.
const isMyUserId = (id, myUserId) => id === myUserId || 'usr' + id === myUserId;
function DeptRow({dept, depth, expanded, containsMe, onToggle}) {
const icon = depth === 0 ? 'business' : 'diamond';
return <button type="button" className={`organization-department depth-${depth}${expanded ? ' expanded' : ''}`}
// 部门本身的蓝色高亮不能跟"是否展开"挂钩:depth 0 的根节点默认就是展开的(见 DeptNode 的
// useState(depth===0)),如果蓝色只看 expanded,根节点会一直被误判成"当前用户所在部门"而
// 高亮蓝色——参考图(部门组织框架.png)里根节点"总裁办50人(一级)"明明是展开状态,颜色却是
// 黑色默认色,只有真正包含当前用户的"技术4人(二级)"才是蓝色实心菱形。展开/收起(chevron
// 方向)继续用 expanded 单独控制,颜色改用 containsMe(本部门自己的成员列表里有没有当前用户,
// 由 DeptNode 根据已加载的 members 算出,不是"是否展开")。
return <button type="button" className={`organization-department depth-${depth}${containsMe ? ' me' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={onToggle}>
<Icon>{icon}</Icon>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({levelLabel(depth + 1)}级)</strong></span>
@@ -250,16 +261,25 @@ function DeptNode({dept, depth, myUserId}) {
const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); };
React.useEffect(() => { if (expanded) load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
// Only this department's own (already-loaded) member list decides the blue highlight -- not
// whether it's merely expanded (see DeptRow's comment) and not whether a *descendant* department
// contains the current user either. Confirmed against 部门组织框架.png with stub data standing in
// for this tenant's empty org tree (real审 GET /organization/departments|members both return zero
// items here): the root "总裁办" is an ancestor of 符小小 (via 技术) but stays black, only 技术
// itself -- whose own direct member list actually contains her -- turns blue. `members` is null
// before this node has ever loaded, so an unexpanded department can't know yet; that's an accepted
// gap (see the fix's commit message), not something this pass invents new backend support for.
const containsMe = (members || []).some(p => isMyUserId(p.user_id, myUserId));
return <>
<DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} />
<DeptRow dept={dept} depth={depth} expanded={expanded} containsMe={containsMe} onToggle={toggle} />
{expanded && loading ? <p className="organization-loading">加载中…</p> : null}
{expanded && !loading && loadError ?
<p className="organization-loading">加载失败 <a href="#" onClick={e => { e.preventDefault(); load(); }}>重试</a></p> : null}
{expanded && !loading && !loadError ? <>
{(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)}
{(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1}
level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)}
level={depth + 2} isMe={isMyUserId(person.user_id, myUserId)} />)}
</> : null}
</>;
}
@@ -581,7 +601,7 @@ function OrganizationDirectory({myUserId}) {
searchResults.map((item, idx) => item.type === 'department' ?
<SearchDeptRow key={'d' + (item.department && item.department.department_id || idx)} dept={item.department} /> :
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level={3}
isMe={item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)} />)}
isMe={!!(item.user && isMyUserId(item.user.user_id, myUserId))} />)}
</section> :
<section className="organization-tree">
{roots == null ? <p className="organization-loading">加载中…</p> :
+100 -14
View File
@@ -69,6 +69,7 @@ class CallPanel extends React.PureComponent {
this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED;
this.containerRef = React.createRef();
this.selfBoxRef = React.createRef();
this.localRef = React.createRef();
this.remoteRef = React.createRef();
@@ -125,6 +126,10 @@ class CallPanel extends React.PureComponent {
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
this.bounds = {};
// Which element the in-progress drag is moving: 'container' for the whole minimized bubble,
// 'self' for just the local self-view preview box inside a fullscreen connected video call.
// Null when no drag is in progress (also doubles as the pointermove/pointerup no-op guard).
this.dragTarget = null;
this.dragStart = (event) => {
if (!event.target.closest('.draggable')) {
@@ -152,27 +157,105 @@ class CallPanel extends React.PureComponent {
const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight;
this.dragTarget = 'container';
return;
}
// Fullscreen connected video call: let the user drag their own small self-view preview
// out of the way instead of it being stuck wherever it defaults to (spec 8.9 "双方视频
// 通话中": "小窗应支持拖动且不能遮挡关键按钮"). Only relevant when the box actually exists
// (base.css hides it entirely for audio-only calls / while minimized, and now also while
// still ringing -- see the render()'s `disabled` prop just below -- via
// .call-party.self[disabled]) and only when the drag actually starts on that box, not on
// the peer's video/avatar card behind it. The `this.state.remoteStream` check mirrors
// render()'s own `connected` gate: without it, a pointerdown during the pre-connect ringing
// phase (box still hidden by CSS, but not yet [disabled] before this fix) would already set
// dragTarget='self' and start moving an invisible box.
if (!this.props.minimized && !this.state.audioOnly && this.state.remoteStream &&
this.selfBoxRef.current && event.target.closest('.call-party.self')) {
event.stopPropagation();
const el = this.selfBoxRef.current;
// Pointer events are captured on the outer container (same element the listeners below
// are attached to) rather than the box itself, purely so drag()/dragEnd() below have one
// consistent place to check hasPointerCapture() regardless of which element is moving.
this.containerRef.current.setPointerCapture(event.pointerId);
el.style.cursor = 'grabbing';
this.bounds.left = el.offsetLeft;
this.bounds.top = el.offsetTop;
this.bounds.width = el.offsetWidth;
this.bounds.height = el.offsetHeight;
// Switch from right/bottom CSS positioning to explicit left/top so drag() can move it freely.
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight;
this.dragTarget = 'self';
}
};
this.drag = (event) => {
event.stopPropagation();
const el = this.containerRef.current;
if (this.props.minimized && this.containerRef && el.hasPointerCapture(event.pointerId)) {
// Calculate new position. Restrict to the visible area of the parent element.
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0),
this.bounds.maxX - this.bounds.width);
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0),
this.bounds.maxY - this.bounds.height);
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const container = this.containerRef.current;
if (!this.dragTarget || !container.hasPointerCapture(event.pointerId)) {
return;
}
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
if (!el) {
return;
}
// Calculate new position. Restrict to the visible area of the parent element.
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0),
this.bounds.maxX - this.bounds.width);
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0),
this.bounds.maxY - this.bounds.height);
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
};
this.dragEnd = (event) => {
event.stopPropagation();
if (this.props.minimized && this.containerRef) {
this.containerRef.current.releasePointerCapture(event.pointerId);
this.containerRef.current.style.cursor = 'grab';
const container = this.containerRef.current;
if (!this.dragTarget) {
return;
}
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
if (container && container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
if (el) {
el.style.cursor = 'grab';
}
this.dragTarget = null;
};
// dragStart's inline left/top/right (set the first time the minimized bubble is dragged, see
// above) always win over base.css's #video-container.minimized:not(.audio-minimized) mobile
// rule -- inline style beats any stylesheet selector regardless of media query. A desktop user
// who drags the bubble somewhere and then narrows the window below 640px (a real everyday
// action -- snapping the browser to half the screen, say) would otherwise be stuck with the
// bubble pinned at its old desktop-width position/size instead of docking as the intended
// mobile full-width strip. Clearing the manual offsets once the mobile breakpoint applies lets
// the stylesheet rule take back over; harmless (a no-op) whenever nothing was ever dragged.
// Same idea for the self-view box's own drag above, keyed off its own (768px) breakpoint
// (base.css ".call-party.self" mobile rule only ever touches `bottom`, but a stale inline
// left/top from a desktop-width drag would still leave it sitting at the wrong height once
// that rule's extra bottom clearance was meant to apply).
this.handleWindowResize = () => {
if (this.props.minimized && this.containerRef.current &&
window.matchMedia('(width<=640px)').matches) {
const el = this.containerRef.current;
el.style.removeProperty('left');
el.style.removeProperty('right');
el.style.removeProperty('top');
}
if (!this.props.minimized && this.selfBoxRef.current &&
window.matchMedia('(width<=768px)').matches) {
const el = this.selfBoxRef.current;
el.style.removeProperty('left');
el.style.removeProperty('right');
el.style.removeProperty('top');
el.style.removeProperty('bottom');
}
};
}
@@ -191,6 +274,7 @@ class CallPanel extends React.PureComponent {
this.containerRef.current.addEventListener('pointermove', this.drag);
this.containerRef.current.addEventListener('pointerup', this.dragEnd);
}
window.addEventListener('resize', this.handleWindowResize);
}
componentWillUnmount() {
@@ -199,6 +283,7 @@ class CallPanel extends React.PureComponent {
topic.onInfo = this.previousOnInfo;
this.stop();
window.removeEventListener('resize', this.handleWindowResize);
if (this.containerRef) {
this.containerRef.current.removeEventListener('pointerdown', this.dragStart);
this.containerRef.current.removeEventListener('pointermove', this.drag);
@@ -874,7 +959,8 @@ class CallPanel extends React.PureComponent {
{connected &&
<div className="call-duration">{formatElapsedTime(this.state.elapsedSeconds)}</div>}
<div id="video-container-panel" className="draggable">
<div className="call-party self" disabled={this.state.audioOnly || this.props.minimized}>
<div className="call-party self" ref={this.selfBoxRef}
disabled={this.state.audioOnly || this.props.minimized || !connected}>
<video ref={this.localRef} autoPlay muted playsInline />
<div className="caller-name inactive">
<FormattedMessage id="calls_you_label"
+72 -3
View File
@@ -47,7 +47,10 @@ class BaseChatMessage extends React.PureComponent {
this.state = {
progress: 0,
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq),
invitationBusy: false,
invitationResult: null,
invitationError: null
};
if (props.uploader) {
@@ -62,6 +65,7 @@ class BaseChatMessage extends React.PureComponent {
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDraftyClick = this.handleDraftyClick.bind(this);
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
this.handleAcceptGroupInvitation = this.handleAcceptGroupInvitation.bind(this);
this.formatterContext = {
formatMessage: props.intl.formatMessage.bind(props.intl),
@@ -232,6 +236,39 @@ class BaseChatMessage extends React.PureComponent {
}
}
// See the long comment above messages-view.jsx's handleAcceptGroupInvitation for the backstory --
// this just drives this one message bubble's own busy/done/error display around that call.
handleAcceptGroupInvitation(invitationId) {
if (this.state.invitationBusy || this.state.invitationResult || !this.props.onAcceptGroupInvitation) {
return;
}
this.setState({invitationBusy: true, invitationError: null});
this.props.onAcceptGroupInvitation(invitationId).then(result => {
this.setState({invitationBusy: false, invitationResult: (result && result.status) || 'joined'});
}).catch(err => {
this.setState({invitationBusy: false, invitationError: (err && err.message) || '操作失败,请重试'});
});
}
// docs/images/加入群聊.png: a card with the inviter's notice text and a single "立即加入" button.
// Only rendered for the one system_action template that actually carries an actionable
// `content.actions` entry -- every other system_action/system_notice message keeps falling through
// to the plain-text path right below, unchanged.
renderGroupInvitationCard(text, action) {
const {invitationBusy, invitationResult, invitationError} = this.state;
return <div className="message-group-invitation">
<div>{text}</div>
{invitationResult == 'joined' ? <em>已加入群聊</em> :
invitationResult == 'pending_approval' ? <em>已提交申请,等待管理员通过</em> :
invitationResult ? <em>{invitationResult}</em> :
<button type="button" disabled={invitationBusy}
onClick={() => this.handleAcceptGroupInvitation(action.target_id)}>
{invitationBusy ? '处理中…' : (action.label || '立即加入')}
</button>}
{invitationError ? <small className="message-group-invitation-error">{invitationError}</small> : null}
</div>;
}
// Reason caption shown under a failed outgoing message in a 1:1 chat, based on the real
// relationship state (see docs/images/个人对话—拉黑状态/已读状态.png and .../个人对话—删除状态.png).
// Returns null for relationships that don't explain a send failure (friend, pending, unknown) so
@@ -281,7 +318,22 @@ class BaseChatMessage extends React.PureComponent {
// message like "有成员通过审批加入了群聊。" was rendering as a bare warning icon. Reduce either to
// its `text` upfront so the existing plain-string path renders it as an ordinary bubble -- these
// are meant to read like a normal message, not a special banner.
if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') &&
//
// One template is the exception: `group_invitation_created` (docs/images/加入群聊.png) is the one
// system_action message that actually carries a real, backend-honored action -- confirmed live
// against server/im_http.go's serveIMGroupInvitations, content.actions[0] is
// {type:"group_invitation_accept", label:"立即加入", target_id:<invitation_id>}, and
// acceptGroupInvitation() already existed in im-api.js with zero call sites because this action
// data was being thrown away right here along with everything else system_action carries. Collapsing
// it to bare text (like every other system_action template correctly does) left the invitee with no
// way to ever accept -- the reference image's whole point is the "立即加入" button.
const systemActions = content && typeof content == 'object' && Array.isArray(content.actions) ? content.actions : null;
const inviteAction = this.props.head && this.props.head['x-im-type'] === 'system_action' && systemActions &&
systemActions.find(a => a && a.type === 'group_invitation_accept');
let invitationCard = null;
if (inviteAction && typeof content.text == 'string') {
invitationCard = this.renderGroupInvitationCard(content.text, inviteAction);
} else if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') &&
content && typeof content == 'object' && typeof content.text == 'string') {
content = content.text;
}
@@ -322,6 +374,23 @@ class BaseChatMessage extends React.PureComponent {
// Content consists of 1-5 emoji characters. Count how many and use it to increase the font size.
textSizeClass += ' emoji-' + (content || '').match(/\p{RGI_Emoji}/vg).length;
}
} else if (content == null) {
// Tinode's wire protocol marks a recalled message by nulling its `content` in place (the seq
// slot stays, everything else is gone) -- see server's message.recalled handling and
// IM-前后端对接文档.md 1256行. That's a *different* deletion shape from the msg.hi-flagged kind
// messages-view.jsx already handles by rendering a dedicated <MetaMessage deleted deletedText=.../>
// instead of a ChatMessage at all (deletedText is that component's own prop, not this one's --
// there's no equivalent plumbed in here). Recalled messages still go through ordinary ChatMessage,
// so without this branch a null content fell all the way through to the generic "invalid content"
// case below -- confirmed live: recalling a message left the *other* party's chat showing a bare
// "⚠ invalid content" (无效内容) row where "消息已删除" belongs (the recalling side just drops the
// row from its own list locally, unaffected by this). Reuses the exact same i18n id/defaultMessage
// messages-view.jsx's `messages.message_deleted` already registers (translated zh string:
// "消息已删除"), not a new string. `content == null` (not `===`) intentionally also catches
// `undefined`, same as every other falsy-content branch here.
content = <><i className="material-icons gray">block</i> <i className="gray">
<FormattedMessage id="message_deleted" defaultMessage="message deleted"
description="Placeholder shown in the timeline where a message was recalled or deleted" /></i></>;
} else {
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
@@ -353,7 +422,7 @@ class BaseChatMessage extends React.PureComponent {
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
<div className="content-meta">
<div className={textSizeClass}>
{bundleCard || content}
{invitationCard || bundleCard || content}
{attachments}
</div>
{this.props.timestamp ?
+8
View File
@@ -20,6 +20,14 @@ export default class MetaMessage extends React.PureComponent {
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
bubbleClass += ' deleted';
} else if (this.props.systemText) {
// docs/images/内容沟通对话3-开启进群验证与拉人状态.png:群系统事件(成员加入/退出/角色变更/
// 群公告发布等)在参考图里是居中的纯灰字,不带头像、不带气泡、不带发送者名字——跟这里的
// 日期分隔条是同一种视觉语言。这类消息之前是当成普通消息交给 ChatMessage 渲染的(服务端
// 这类消息没有真实的 `from`,chat-message.jsx 的 fullDisplay 逻辑因此在系统消息下方多画出
// 一行解析不到发送者、回退成"未找到"的作者名,明显不该出现)。
content = <span className="gray">{this.props.systemText}</span>;
bubbleClass += ' system';
}
if (!content) {
+349 -286
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -56,6 +56,7 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
this.handleLogoutRequest = this.handleLogoutRequest.bind(this);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
}
handlePasswordUpdate(pwd) {
@@ -95,6 +96,10 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
} = this.props.intl;
this.props.onShowAlert(formatMessage(messages.delete_account), formatMessage(messages.delete_account_warning), _ => this.props.onDeleteAccount(), null, true, null);
}
handleLogoutRequest(e) {
e.preventDefault();
this.props.onShowAlert('退出登录', '确定要退出当前登录吗?', _ => this.props.onLogout(), null, true, null);
}
render() {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: this.state.showPermissionEditorFor ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(_widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
@@ -203,10 +208,7 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
href: "#",
className: "danger flat-button",
onClick: e => {
e.preventDefault();
this.props.onLogout();
},
onClick: this.handleLogoutRequest,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
className: "material-icons",
children: "exit_to_app"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,319 +0,0 @@
"use strict";
(self["webpackChunktinode_webapp"] = self["webpackChunktinode_webapp"] || []).push([["src_views_create-account-view_jsx"],{
/***/ "./src/views/create-account-view.jsx":
/*!*******************************************!*\
!*** ./src/views/create-account-view.jsx ***!
\*******************************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": function() { return /* binding */ CreateAccountView; }
/* harmony export */ });
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ "react");
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-intl */ "react-intl");
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
/* harmony import */ var _widgets_avatar_crop_jsx__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../widgets/avatar-crop.jsx */ "./src/widgets/avatar-crop.jsx");
/* harmony import */ var _widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../widgets/avatar-upload.jsx */ "./src/widgets/avatar-upload.jsx");
/* harmony import */ var _widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../widgets/checkbox.jsx */ "./src/widgets/checkbox.jsx");
/* harmony import */ var _widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ../widgets/phone-edit.jsx */ "./src/widgets/phone-edit.jsx");
/* harmony import */ var _widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../widgets/visible-password.jsx */ "./src/widgets/visible-password.jsx");
/* harmony import */ var _lib_local_storage_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ../lib/local-storage.js */ "./src/lib/local-storage.js");
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
/* harmony import */ var _lib_utils_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ../lib/utils.js */ "./src/lib/utils.js");
/* harmony import */ var _lib_strformat_js__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ../lib/strformat.js */ "./src/lib/strformat.js");
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
class CreateAccountView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
constructor(props) {
super(props);
this.state = {
login: '',
password: '',
meth: '',
email: '',
tel: '',
fn: '',
imageUrl: null,
uploadUrl: null,
newAvatar: null,
newAvatarMime: null,
buttonDisabled: false,
saveToken: _lib_local_storage_js__WEBPACK_IMPORTED_MODULE_7__["default"].getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePhoneChange = this.handlePhoneChange.bind(this);
this.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
this.uploadAvatar = this.uploadAvatar.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
props.tinode.connect().catch(err => {
this.props.onError(err.message, 'err');
});
}
handleLoginChange(e) {
this.setState({
login: e.target.value
});
}
handlePasswordChange(password) {
this.setState({
password: password
});
}
handleEmailChange(e) {
this.setState({
meth: 'email',
email: e.target.value
});
}
handlePhoneChange(number) {
this.setState({
meth: 'tel',
tel: number
});
}
handleFnChange(e) {
this.setState({
fn: e.target.value
});
}
handleImageChanged(mime, img) {
this.setState({
newAvatar: img,
newAvatarMime: mime
});
if (!img) {
this.setState({
imageUrl: null,
uploadUrl: null
});
}
}
handleToggleSaveToken() {
_lib_local_storage_js__WEBPACK_IMPORTED_MODULE_7__["default"].setObject('keep-logged-in', !this.state.saveToken);
this.setState({
saveToken: !this.state.saveToken
});
}
handleSubmit(e) {
e.preventDefault();
this.props.onCreateAccount(this.state.login.trim(), this.state.password.trim(), (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_9__.theCard)((0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_10__.truncateString)(this.state.fn.trim(), _config_js__WEBPACK_IMPORTED_MODULE_11__.MAX_TITLE_LENGTH), this.state.uploadUrl), {
'meth': this.state.meth,
'val': this.state.meth == 'email' ? this.state.email : this.state.meth == 'tel' ? this.state.tel : null
});
}
handleAvatarCropped(mime, blob, width, height) {
const url = blob ? URL.createObjectURL(blob) : null;
this.setState({
newAvatar: null,
newAvatarMime: null
});
if (blob) {
this.uploadAvatar(mime, blob, width, height);
}
}
handleAvatarCropCancel() {
this.setState({
newAvatar: null,
newAvatarMime: null
});
}
uploadAvatar(mime, blob, width, height) {
const readyToUpload = image => {
let {
mime,
blob
} = image;
this.setState({
imageUrl: URL.createObjectURL(blob),
buttonDisabled: true
});
if (blob.size > _config_js__WEBPACK_IMPORTED_MODULE_11__.MAX_AVATAR_BYTES) {
const uploader = this.props.tinode.getLargeFileHelper();
uploader.upload(blob, 'newacc').then(url => this.setState({
uploadUrl: url
})).catch(err => this.props.onError(err.message, 'err')).finally(_ => this.setState({
buttonDisabled: false
}));
} else {
(0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__.blobToBase64)(blob).then(b64 => this.setState({
uploadUrl: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__.makeImageUrl)({
data: b64.bits,
type: mime
})
})).finally(_ => this.setState({
buttonDisabled: false
}));
}
};
if (width > _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE || height > _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE || width != height) {
(0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__.imageScaled)(blob, _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE, _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE, _config_js__WEBPACK_IMPORTED_MODULE_11__.MAX_EXTERN_ATTACHMENT_SIZE, true).then(scaled => readyToUpload(scaled)).catch(err => this.props.onError(err.message, 'err'));
} else {
readyToUpload({
mime: mime,
blob: blob,
width: width,
height: height
});
}
}
render() {
if (this.state.newAvatar) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_avatar_crop_jsx__WEBPACK_IMPORTED_MODULE_2__["default"], {
avatar: this.state.newAvatar,
mime: this.state.newAvatarMime,
onSubmit: this.handleAvatarCropped,
onCancel: this.handleAvatarCropCancel,
onError: this.props.onError
}, void 0, false);
}
let submitClasses = 'primary';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("form", {
className: "panel-form-column",
onSubmit: this.handleSubmit,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "umn",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "login_prompt",
defaultMessage: "Login",
description: "Placeholer for username/login",
children: login_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("input", {
type: "text",
placeholder: login_prompt,
autoComplete: "user-name",
value: this.state.login,
onChange: this.handleLoginChange,
required: true,
autoFocus: true
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "password_prompt",
defaultMessage: "Password",
description: "Placeholder/prompt for entering password",
children: password_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
placeholder: password_prompt,
autoComplete: "new-password",
value: this.state.password,
onFinished: this.handlePasswordChange,
required: true
}, void 0, false)
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
tinode: this.props.tinode,
avatar: this.state.imageUrl,
onImageUpdated: this.handleImageChanged,
onError: this.props.onError
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "full_name_prompt",
defaultMessage: "Full name, e.g. John Doe",
description: "Input placeholder for person's full name",
children: full_name_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("input", {
type: "text",
placeholder: full_name_prompt,
autoComplete: "name",
value: this.state.fn,
onChange: this.handleFnChange,
required: true
}, void 0, false)
}, void 0, false)
}, void 0, false), this.props.reqCredMethod == 'email' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "email_prompt",
defaultMessage: "Email, e.g. jdoe@example.com",
description: "Input placeholder for email entry",
children: email_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("input", {
type: "email",
placeholder: email_prompt,
autoComplete: "email",
value: this.state.email,
onChange: this.handleEmailChange,
required: true
}, void 0, false)
}, void 0, false)
}, void 0, false) : this.props.reqCredMethod == 'tel' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("label", {
className: "small gray",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "mobile_phone_number",
defaultMessage: "Mobile phone number",
description: "Prompt for entering a mobile phone number"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
autoFocus: false,
onShowCountrySelector: this.props.onShowCountrySelector,
onSubmit: this.handlePhoneChange
}, void 0, false)
}, void 0, false)]
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
id: "save-token",
name: "save-token",
checked: this.state.saveToken,
onChange: this.handleToggleSaveToken
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "stay_logged_in",
defaultMessage: "Stay logged in",
description: "Label for a checkbox",
children: stay_logged_in => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("label", {
htmlFor: "save-token",
children: ["\xA0", stay_logged_in]
}, void 0, true)
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
className: "dialog-buttons",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("button", {
className: submitClasses,
type: "submit",
disabled: this.state.buttonDisabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "button_sign_up",
defaultMessage: "Sign up",
description: "Create account button [Sign Up]"
}, void 0, false)
}, void 0, false)
}, void 0, false)]
}, void 0, true);
}
}
;
/***/ })
}]);
//# sourceMappingURL=src_views_create-account-view_jsx.dev.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,276 +0,0 @@
"use strict";
(self["webpackChunktinode_webapp"] = self["webpackChunktinode_webapp"] || []).push([["src_views_password-reset-view_jsx"],{
/***/ "./src/views/password-reset-view.jsx":
/*!*******************************************!*\
!*** ./src/views/password-reset-view.jsx ***!
\*******************************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ "react");
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-intl */ "react-intl");
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
/* harmony import */ var _widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../widgets/phone-edit.jsx */ "./src/widgets/phone-edit.jsx");
/* harmony import */ var _widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../widgets/visible-password.jsx */ "./src/widgets/visible-password.jsx");
/* harmony import */ var _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../lib/navigation.js */ "./src/lib/navigation.js");
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
password_reset_email_sent: {
id: 'password_reset_email_sent',
defaultMessage: 'An email with security code has been sent.',
description: 'Notification that the email with password reset instructions has been sent'
},
password_reset_sms_sent: {
id: 'password_reset_sms_sent',
defaultMessage: 'A text message with security code has been sent.',
description: 'Notification that the SMS with password reset instructions has been sent'
}
});
class PasswordResetView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
constructor(props) {
super(props);
this.state = {
tel: '',
email: '',
password: '',
sent: false,
haveCode: false,
code: ''
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleCodeChange = this.handleCodeChange.bind(this);
this.handleShowCodeField = this.handleShowCodeField.bind(this);
props.tinode.connect().catch(err => {
this.props.onError(err.message, 'err');
});
}
componentDidMount() {
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].parseUrlHash(window.location.hash);
const newState = {
token: parsed.params.token,
scheme: parsed.params.scheme,
code: parsed.params.code || ''
};
this.savedCred = parsed.params.cred;
if (this.props.reqCredMethod && parsed.params.cred) {
newState[this.props.reqCredMethod] = this.savedCred;
}
this.setState(newState);
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.reqCredMethod != this.props.reqCredMethod && this.props.reqCredMethod) {
this.setState({
[this.props.reqCredMethod]: this.savedCred || ''
});
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
this.props.onReset(this.state.password.trim(), {
scheme: 'token',
secret: this.state.token
});
} else if (this.state.code && this.props.reqCredMethod) {
const cred = this.props.reqCredMethod == 'email' ? this.state.email.trim() : this.state.tel.trim();
this.props.onReset(this.state.password.trim(), {
scheme: 'code',
secret: btoa(`${this.state.code}:${this.props.reqCredMethod}:${cred}`)
});
} else {
const email = this.state.email.trim();
const tel = this.state.tel.trim();
this.setState({
email: email,
tel: tel
});
this.props.onRequest(this.props.reqCredMethod, email || tel).then(_ => {
this.setState({
sent: true
});
const msg = this.props.reqCredMethod == 'email' ? messages.password_reset_email_sent : this.props.reqCredMethod == 'tel' ? messages.password_reset_sms_sent : null;
if (msg) {
this.props.onError(this.props.intl.formatMessage(msg), 'info');
}
});
}
}
handleEmailChange(e) {
this.setState({
email: e.target.value
});
}
handlePasswordChange(e) {
this.setState({
password: e.target.value
});
}
handleCodeChange(e) {
this.setState({
code: e.target.value.replace(/[^\d]/g, '')
});
}
handleShowCodeField(e) {
e.preventDefault();
this.setState({
haveCode: true
});
}
render() {
const showCredentialInput = !((this.state.token || this.state.code && this.props.reqCredMethod) && this.state.scheme);
const showPasswordInput = !showCredentialInput || this.state.haveCode || this.state.sent;
const passwordInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
className: "small gray",
htmlFor: "new-password",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_new_password",
defaultMessage: "New password",
description: "Label for entering a new password"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "new_password_placeholder",
defaultMessage: "Enter new password",
description: "Placeholder for entering new password",
children: placeholder => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(_widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
id: "new-password",
placeholder: placeholder,
autoComplete: "new-password",
value: this.state.password,
required: true,
autoFocus: true,
onChange: this.handlePasswordChange
}, void 0, false)
}, void 0, false)]
}, void 0, true);
const emailInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: [this.state.haveCode ? null : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
htmlFor: "inputEmail",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_reset_password",
defaultMessage: "Send a password reset email",
description: "Label for password reset field"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "credential_email_prompt",
defaultMessage: "Your registration email",
description: "Placeholder for entering email",
children: placeholder => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("input", {
type: "email",
id: "inputEmail",
placeholder: placeholder,
autoComplete: "email",
value: this.state.email,
onChange: this.handleEmailChange,
required: true,
autoFocus: true
}, void 0, false)
}, void 0, false)]
}, void 0, true);
const phoneInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: [this.state.haveCode ? null : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_reset_password_tel",
defaultMessage: "Send SMS to reset password",
description: "Label for password reset field by phone"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
className: "small gray",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "mobile_phone_number",
defaultMessage: "Mobile phone number",
description: "Prompt for entering a mobile phone number"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
className: "panel-form-row",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(_widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_2__["default"], {
autoFocus: true,
onShowCountrySelector: this.props.onShowCountrySelector,
onSubmit: number => this.setState({
tel: number
})
}, void 0, false)
}, void 0, false)]
}, void 0, true);
const codeInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
className: "small gray",
htmlFor: "enter-confirmation-code",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "enter_confirmation_code_prompt",
defaultMessage: "Confirmation code",
description: "Request to enter confirmation code"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "numeric_confirmation_code_prompt",
defaultMessage: "Numbers only",
description: "Prompt for numeric conformation code",
children: numbers_only => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("input", {
type: "text",
id: "enter-confirmation-code",
placeholder: numbers_only,
maxLength: 10,
value: this.state.code,
onChange: this.handleCodeChange,
required: true
}, void 0, false)
}, void 0, false)
}, void 0, false)]
}, void 0, true);
const credentialInput = this.props.reqCredMethod == 'email' ? emailInput : this.props.reqCredMethod == 'tel' ? phoneInput : null;
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("form", {
id: "password-reset-form",
onSubmit: this.handleSubmit,
children: [!this.state.sent && showCredentialInput ? credentialInput : null, this.state.haveCode || this.state.sent ? codeInput : null, showPasswordInput ? passwordInput : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
className: "dialog-buttons",
children: [this.state.haveCode || this.state.sent || this.state.token || this.state.code ? null : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
href: "#",
onClick: this.handleShowCodeField,
style: {
marginRight: 'auto'
},
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "password_i_have_code",
defaultMessage: "I have code",
description: "Call to open field to enter password reset code"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
className: "primary",
type: "submit",
children: showPasswordInput ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "button_reset",
defaultMessage: "Reset",
description: "Button [Reset]"
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "button_send_request",
defaultMessage: "Send request",
description: "Button [Send request]"
}, void 0, false)
}, void 0, false)]
}, void 0, true)]
}, void 0, true);
}
}
/* harmony default export */ __webpack_exports__["default"] = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(PasswordResetView));
/***/ })
}]);
//# sourceMappingURL=src_views_password-reset-view_jsx.dev.js.map
File diff suppressed because one or more lines are too long
+63 -10
View File
@@ -71,6 +71,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
};
this.isOutgoingCall = props.callState == _constants_js__WEBPACK_IMPORTED_MODULE_4__.CALL_STATE_OUTGOING_INITATED;
this.containerRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
this.selfBoxRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
this.localRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
this.remoteRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
this.remoteIceCandidatesCache = [];
@@ -113,6 +114,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.handleDataChannelOpen = this.handleDataChannelOpen.bind(this);
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
this.bounds = {};
this.dragTarget = null;
this.dragStart = event => {
if (!event.target.closest('.draggable')) {
return;
@@ -135,23 +137,71 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight;
this.dragTarget = 'container';
return;
}
if (!this.props.minimized && !this.state.audioOnly && this.state.remoteStream && this.selfBoxRef.current && event.target.closest('.call-party.self')) {
event.stopPropagation();
const el = this.selfBoxRef.current;
this.containerRef.current.setPointerCapture(event.pointerId);
el.style.cursor = 'grabbing';
this.bounds.left = el.offsetLeft;
this.bounds.top = el.offsetTop;
this.bounds.width = el.offsetWidth;
this.bounds.height = el.offsetHeight;
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight;
this.dragTarget = 'self';
}
};
this.drag = event => {
event.stopPropagation();
const el = this.containerRef.current;
if (this.props.minimized && this.containerRef && el.hasPointerCapture(event.pointerId)) {
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0), this.bounds.maxX - this.bounds.width);
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0), this.bounds.maxY - this.bounds.height);
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const container = this.containerRef.current;
if (!this.dragTarget || !container.hasPointerCapture(event.pointerId)) {
return;
}
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
if (!el) {
return;
}
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0), this.bounds.maxX - this.bounds.width);
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0), this.bounds.maxY - this.bounds.height);
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
};
this.dragEnd = event => {
event.stopPropagation();
if (this.props.minimized && this.containerRef) {
this.containerRef.current.releasePointerCapture(event.pointerId);
this.containerRef.current.style.cursor = 'grab';
const container = this.containerRef.current;
if (!this.dragTarget) {
return;
}
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
if (container && container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
if (el) {
el.style.cursor = 'grab';
}
this.dragTarget = null;
};
this.handleWindowResize = () => {
if (this.props.minimized && this.containerRef.current && window.matchMedia('(width<=640px)').matches) {
const el = this.containerRef.current;
el.style.removeProperty('left');
el.style.removeProperty('right');
el.style.removeProperty('top');
}
if (!this.props.minimized && this.selfBoxRef.current && window.matchMedia('(width<=768px)').matches) {
const el = this.selfBoxRef.current;
el.style.removeProperty('left');
el.style.removeProperty('right');
el.style.removeProperty('top');
el.style.removeProperty('bottom');
}
};
}
@@ -167,12 +217,14 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.containerRef.current.addEventListener('pointermove', this.drag);
this.containerRef.current.addEventListener('pointerup', this.dragEnd);
}
window.addEventListener('resize', this.handleWindowResize);
}
componentWillUnmount() {
this._unmounted = true;
const topic = this.props.tinode.getTopic(this.props.topic);
topic.onInfo = this.previousOnInfo;
this.stop();
window.removeEventListener('resize', this.handleWindowResize);
if (this.containerRef) {
this.containerRef.current.removeEventListener('pointerdown', this.dragStart);
this.containerRef.current.removeEventListener('pointermove', this.drag);
@@ -772,7 +824,8 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
className: "draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-party self",
disabled: this.state.audioOnly || this.props.minimized,
ref: this.selfBoxRef,
disabled: this.state.audioOnly || this.props.minimized || !connected,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("video", {
ref: this.localRef,
autoPlay: true,
File diff suppressed because one or more lines are too long