Compare commits

...
1577 Commits
Author SHA1 Message Date
Your Name df173879fa fix: route IM clients through current origin
Deploy im-webapp production / deploy (push) Successful in 1m35s
2026-10-19 17:23:34 +08:00
richjohnrambo b0d1199fb6 Merge source dev into pro
Deploy im-webapp production / deploy (push) Successful in 42s
2026-10-19 11:30:39 +08:00
richjohnrambo c063d056b2 fix: lock IM tenant to default 2026-10-19 11:28:07 +08:00
Your Name d7bfd2f417 fix: route production IM API correctly
Deploy im-webapp production / deploy (push) Successful in 42s
2026-10-19 11:05:50 +08:00
Your Name d2b4cb0440 ci: deploy pro with docker compose
Deploy im-webapp production / deploy (push) Successful in 1m23s
2026-10-18 20:11:07 +08:00
richjohnrambo 72c4f4e54d fix: defer revoking group file download URL 2026-10-17 17:09:06 +08:00
richjohnrambo dd29eaca42 fix(web): sync group member nicknames 2026-10-17 10:12:20 +08:00
richjohnrambo 8bdb8ed5a5 feat(web): show legal documents in registration dialogs 2026-10-17 01:30:03 +08:00
richjohnrambo dca34b8b5d fix(web): open legal documents on IM API host 2026-10-17 01:23:41 +08:00
richjohnrambo c30c5c07c7 feat(web): add tenant user registration flow 2026-10-17 00:48:08 +08:00
richjohnrambo 361e23b9ec fix p2p conversation avatars 2026-10-14 16:09:38 +08:00
richjohnrambo 8a26a4ccf9 fix production attachment file origin 2026-10-14 15:22:46 +08:00
richjohnrambo c1338985d4 chore: remove unused service worker cleanup 2026-10-14 14:24:37 +08:00
richjohnrambo 440e29975a chore: remove unused web code and dependencies 2026-10-14 14:10:41 +08:00
richjohnrambo b7f214daf5 remove legacy root Tinode webapp 2026-10-14 14:01:52 +08:00
richjohnrambo ec22b709b5 fix legacy attachment URLs in Vite web 2026-10-14 13:58:33 +08:00
richjohnrambo 0570f2c5bd fix legacy chat attachment URLs 2026-10-14 13:38:01 +08:00
Your Name dd77ba903b Queue message sound while audio is priming 2026-09-30 17:05:32 +08:00
Your Name 06df51ca08 Prime message audio and silence duplicate OS notification sound 2026-09-30 17:01:34 +08:00
Your Name 1c17165c86 Brand web client and add message notification sound 2026-09-30 16:50:14 +08:00
Your Name 560269c299 docs: plan web branding and message sound 2026-09-30 16:41:39 +08:00
Your Name 7b8216ea70 fix(web): prevent negative unread counts 2026-09-30 15:58:34 +08:00
Your Name bd61be8a86 feat(web): support group avatar updates 2026-09-30 15:28:50 +08:00
Your Name cdd46c0b62 docs: plan web clipboard image compatibility 2026-09-29 17:37:38 +08:00
Your Name 96acc6cb39 docs: design web clipboard image paste compatibility 2026-09-29 17:32:40 +08:00
Your NameandClaude Sonnet 5 975dc77111 Revert "网页版支持粘贴/选择多张图片暂存,可配文字一起发送"
用户反馈这个交互做得不够好,要求退回上一版(保留粘贴发图/保存图片,去掉多图暂存+图文一起发)。

This reverts commit 6bcfc08943.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:12:29 +08:00
Your NameandClaude Sonnet 5 6bcfc08943 网页版支持粘贴/选择多张图片暂存,可配文字一起发送
老板反馈:粘贴图片、选择文件都是立即发送,体验不好;想要能粘贴多张图片,并且可以带着文字一起发,
不带文字也要能发。

- 粘贴图片、点击图片图标选图(新增 multiple 多选)现在都不再立即发送,改成暂存进输入框上方的
  缩略图预览条,可以继续粘贴/选择叠加更多图片,每张都能单独点掉重新移除
- 发送(Enter / 点击发送按钮)时,把暂存的图片按添加顺序依次发出去,再发文字(如果有输入的话);
  没有文字、只有图片也能发;没有图片、只有文字保持原有行为不变
- 引用回复只附着在这一批里实际发出的第一条消息上,跟"引用只作用于下一条消息"的既有规则保持一致
- 单张图片发送失败不影响这一批里的其他图片和后面的文字,各自独立报错提示
- 参考设计图(语音图片发送状态.png)确认了这个后端每种附件类型都是独立气泡,没有"图文合并成一条
  消息"的协议形态,所以是按多条消息依次发送实现的,不是改协议

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:06:24 +08:00
Your NameandClaude Sonnet 5 3d48abdf73 网页版支持粘贴图片发送、聊天图片保存到本地
老板反馈:"没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能"

- 聊天输入框新增粘贴图片检测:剪贴板里有图片(截图工具/复制图片)时直接走发图逻辑,
  不再要求先存到电脑里再用文件选择器上传;普通文本粘贴不受影响
- "查看大图"预览里新增"保存图片"下载按钮,取消息自带的原始文件名,
  取不到时按mime类型生成默认文件名

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 02:42:21 +08:00
Your NameandClaude Sonnet 5 9abae5030b 修复codereview复核确认的多选/转发/收藏页/资料页等前端bug
- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页
- 语音/视频消息在多选模式下点击可正常冒泡选中该行
- 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示
- 群文件"更多操作"菜单竞态、群成员移出状态竞态
- 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界
- 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 16:47:32 +08:00
Your NameandClaude Sonnet 5 e3eed8bdd4 Add click-to-view lightbox with zoom for chat images
Tapping a chat image opens a full-screen viewer (same resolved
image source, no extra fetch) with zoom in/out buttons, mouse-wheel
zoom, drag-to-pan once zoomed, and double-click to toggle zoom.
Wheel zoom uses a native non-passive listener rather than JSX onWheel,
since a passive listener's preventDefault() is silently ignored and
would leave the page scrolling under the lightbox.

Also closes a gap the row-click-to-select change (1cc6f506) left open:
image/video/audio/file attachments inside a bubble had no selectMode
awareness, so tapping one during bulk-select would also open the
lightbox / play media / start a download alongside toggling the row.
Added a `locked` prop to all four attachment components (disabling
their own click behavior without changing how they look) and wired
`locked={selectMode}` from both MessageBubble.tsx and drafty.tsx's
IM/EX/AU/VD cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:32:07 +08:00
Your NameandClaude Sonnet 5 aca5f8609b Fix misleading "clear chat history" confirmation copy
The dialog said clearing was "local record only" -- reading the real
backend handler (im_be's Topic.replyDelMsg) shows a soft delete
persists a per-account delete watermark server-side, visible across
every device/session on that account and not reversible through any
normal client action. Only the "doesn't affect the other party" half
of the old copy was accurate. Updated both the P2P and group versions
to say what actually happens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:21:56 +08:00
Your NameandClaude Sonnet 5 1cc6f5068a Make whole message row tappable to toggle selection
The selection checkbox was the only clickable target in multi-select
mode; tapping the rest of the row (bubble, avatar, timestamp) did
nothing. Moves the toggle handler to the row container so tapping
anywhere selects/deselects, matching the multi-select UX in every
other list this app has (favorites, blocklist, etc).

Nested controls that keep their own click behavior during selectMode
(retry-failed-send, @mention profile card, quote-bar jump-to-message)
would otherwise double-fire alongside the new row click -- retry stops
propagation, mentions and the quote bar go inert instead (same
treatment this file already gives the avatar/sender-name buttons
during selectMode), so a tap only ever does the one thing: toggle the
row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 13:42:27 +08:00
Your NameandClaude Sonnet 5 95c0677b55 Fix forward comment landing before forwarded content
The comment send was fired in parallel with the forward REST call,
not chained after it -- confirmed live with a 2-source bulk forward,
where the comment (plain WS publish, fast) landed before the forwarded
messages (async endpoint, responds with status:"pending" before the
messages are actually persisted server-side). Chains the comment send
off the forward promise instead of starting both at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:37:01 +08:00
Your NameandClaude Sonnet 5 4cd2b33c53 Migrate message forwarding to backend REST endpoint
Both real mobile clients (iOS, Android) forward messages via POST
/v1/im/messages/forward, never a client-side {pub} packet -- confirmed
by reading their source directly. Switches web's single-message and
bulk forward paths to the same endpoint, which copies message content
verbatim regardless of shape, eliminating the client-side flat-content
detection this used to require. Comment-sending stays on the existing
sendText() path, unchanged, to preserve current ordering.

Removes send.ts's now-unused forwardMessage/flatForwardPayload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:27:37 +08:00
Your NameandClaude Sonnet 5 e7cb4edf3a 读了iOS/Android真实源码后修正:busy确实是真实的通话状态,之前判断错了
早前(Part I/K)判断"busy从未在持久化消息里真实出现过,只是本应用自己在
live信令上用的值",所以没把它并入 CallWebrtcState,只在本地 UI 状态单独
加了个 union。这次拿到手机端真实源码核对,iOS(ServerMessages.swift的7态
枚举)和Android(MsgServerData.java的BUSY)都把busy当成服务端下发的正式
head.webrtc取值之一,和accepted/finished等其余几个是同一级别的东西,
不是本应用凭空发明的——之前的判断依据不足。

补上 case 'busy' 的通话结果文案("对方忙线中",和已有的实时来电忙线提示
用词一致),去掉 stores/calls.ts 里两处不再需要的 `| 'busy'` 单独union。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:51:28 +08:00
Your NameandClaude Sonnet 5 1d383b4e93 转发消息的来源标记改用手机端同款字段,修复网页转发在手机端认不出"转发"的问题
网页版转发功能此前一直用自造的 head.forwarded: "topic:seq" 单字符串字段记录
来源,且这个字段在整个网页项目里从未被任何地方读取过(纯写入,无消费者)。

数据库排查(排查转发内容丢失bug时顺带确认)发现手机端自己的转发用的是完全
不同的字段:head['x-im-forwarded']:true + head['x-im-forward-source']:
{seq,from,topic},是真实双端数据。网页版这个自造格式手机端根本不认识,
意味着从网页转发出去的消息,即使内容显示正常,手机端也大概率认不出"这是
一条转发消息",不会显示对应的转发来源提示(如果手机端UI有这个功能的话)。

改成手机端同款的两字段格式。确认这不影响网页自己的任何逻辑(旧字段确认无
读取方)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:34:44 +08:00
Your NameandClaude Sonnet 5 ed2920275d 修复资料弹窗点击自己头像必定加载失败
GET /users/{id}/profile 对自己的id恒定400(和更早修过的群内自己资料
400是同一类self-lookup限制),点击群成员格子/列表里自己的头像会一直
显示"加载失败,请重试"。改成自己走专门的 GET /me/profile(没有这个
限制),归一化成和他人资料相同的字段结构供同一套渲染逻辑使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:49:06 +08:00
Your NameandClaude Sonnet 5 0ba5c97d78 codereview修复:资料弹窗好友关系判断、转发内容克隆、账号切换缓存残留等
三路独立codereview(群成员UI+资料弹窗 / 未读红点+转发修复 / 搜索+复制修复)
发现并修复:

- UserCardRoot 资料弹窗此前完全没读 profile.relationship,对陌生人也直接
  显示"发消息"/通话按钮——现在按好友/待验证/对方已申请/不可添加/添加好友
  五态分支,和 FriendProfileView 保持一致,接入 AddFriendModal。
- useEscapeKey 注册位置错误:挂在常驻的 UserCardRoot 而不是随卡片开关的
  UserCardPanel,导致从其他弹层里打开资料卡片时,按 Escape 关掉的是底下
  那层弹层而不是卡片本身。
- "移出"格子的可见性此前只判断 isAdmin,在"群主+多个管理员、没有普通成员"
  的群里会显示格子但点进去一个能移出的都没有——改成同时判断成员列表里
  是否真的存在能移出的对象。
- flatForwardPayload 对图片/文件/语音/视频/名片/通话结果类消息,转发时直接
  返回了原消息的 content 对象引用而不是拷贝——SDK 出站前会就地删除假值
  字段,等于转发的同时悄悄改写了原消息在本地缓存里的内容。改成每次转发都
  返回浅拷贝;顺手让检查顺序和其余识别这几种格式的地方保持一致(先判类型
  明确的附件/名片/通话结果,再判无类型标记的纯文本),并补上和
  mobileAttachmentContentOf/contactCardContentOf 同样的字段合法性校验。
- confirmedReadSeq 等聊天列表相关的模块级缓存此前从未在登出/切换企业时清
  空——同一个标签页不刷新页面直接换账号登录,会把上一个账号的"已读到哪"
  当成新账号的下限,把新账号真实未读的自聊消息误判为已读。补了统一的
  reset监听机制(connection.ts 的 resetTinode 现在会广播),topics.ts 据此
  清空所有相关缓存。
- 复制功能补final `?? ''`兜底,防止附件数据本身不完整时仍复制出"undefined"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:45:34 +08:00
Your NameandClaude Sonnet 5 105ff76959 排查转发bug同类问题:修复查找聊天记录崩溃 + 复制功能复制出"undefined"
审查了所有"读取消息content做数据处理"的地方,找到两处和转发bug同一根因
(手机端扁平非Drafty内容格式)的真实问题:

1. 查找聊天记录(HistorySearchModal)的搜索结果渲染,对手机端发送的图片/文件/
   语音/视频/名片消息命中关键词时会让整个应用崩溃——Drafty.toPlainText在这
   类扁平内容上返回undefined,highlightMatches对undefined调用.toLowerCase()
   直接抛异常,而这个应用完全没有错误边界(ErrorBoundary)兜底,等于白屏。
   已用真实搜索接口返回确认snapshot里其实带着head字段(此前SearchHit类型
   没建模),据此在渲染前识别这几种扁平内容并显示[图片]/[文件]/[语音]/[名片]
   这样的占位文案,不再无脑喂给renderDrafty;顺手给highlightMatches补了防御
   性判空,避免类似疏漏再次崩溃整个应用。

2. 消息右键菜单"复制"功能,对手机端发送的扁平文本消息(如群公告自动发布的
   那条)和图片/文件/语音/视频消息,复制到剪贴板的是字面字符串"undefined"。
   文本类修成正确提取真实文本;附件类直接从"复制"这个操作里去掉(参考名片/
   通话记录消息已有的排除逻辑),复制一段JSON式的附件数据本来就没有意义。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:06:10 +08:00
Your NameandClaude Sonnet 5 eb0a6eba7e 修复转发含flat非Drafty内容的消息(手机端文本/图片/语音/视频/名片/通话结果)导致content丢失显示"已删除"
真实数据库排查确认根因:forwardMessage()原来无条件把 msg.content 喂给
Drafty.forwardedContent(toDraftyContent(...)),假设内容永远是纯字符串或真
Drafty文档({txt,fmt,ent})。但手机端把文本/图片/语音/视频/名片都发成扁平非
Drafty JSON(如 {"text":"11"}),只靠 head['x-im-type']/head.webrtc 标记类型
——对这类对象跑 Drafty 处理会退化成空内容,SDK 出站的 simplify() 又会把空
content 整个从包里剥掉,服务端收到的 {pub} 根本没有 content 字段,落库后
content/content_nonce 都是真正的 NULL,客户端据此正确地(从自己的角度)把
它当成"已撤回"渲染成"⚠ 消息已删除"。

用 SSH+kubectl exec 直接查了k3s上的真实MySQL(meeting_im库, im_003_group_
messages_202609/im_003_messages两张表),确认了具体是哪几条消息、它们的
head/content 真实长什么样,而不是猜测。

修复:flatForwardPayload() 识别这几种扁平内容形状,文本类重建成真正的
Drafty文本,附件/名片/通话结果类原样保留内容和 x-im-type/webrtc 标记(接收
端已有的对应dispatch逻辑本来就认识这些形状),只有真正的纯字符串/Drafty
内容才继续走原来的 Drafty.forwardedContent() 路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 20:59:37 +08:00
Your NameandClaude Sonnet 5 0ace395cfb 修复群聊自己发消息后聊天列表出现虚假未读红点
reconcileMeSubs() 的周期性 me.getMeta(withLaterSub()) 拉取,对已知会话原有的
回归保护(seq<=prev.seq && unread>prev.unread) 只防得住"seq没变但unread变大"
这一种情形。自己在已打开的会话里发消息时:本地 confirmedReadSeq 会同步立即
更新(早于服务端),但发给服务端的 note read 是异步的;如果这次拉取的请求
恰好在发送前就已经在途,返回时 seq 已经真实前进(不满足 seq<=prev.seq)、
read 却还没追上,旧保护不生效,虚假的 unread 就被原样套用——哪怕这条消息
就是自己刚发的。

补一个独立的、无条件的兜底:用已有的 confirmedReadSeq 记录做下限,read 永远
不会被这次拉取拉回到低于"本客户端已确认看过的位置",与原有保护并存、不冲突。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:02:25 +08:00
Your NameandClaude Sonnet 5 ef31eb2e88 新增点击头像/昵称查看用户资料弹窗,接入消息气泡/@提及/群成员列表
新增全局 UserCardRoot(stores/userCard.ts 驱动,挂载在 AppShell 里,模式同
ConfirmDialogRoot),复用 getUserProfile 数据源,展示头像/昵称/性别地区/签名,
非本人时带发消息/语音/视频快捷操作,底部"查看完整资料"跳转到完整的
/contacts/{id} 页面。

接入点:
- MessageBubble.tsx:群聊和单聊的对方头像/昵称(原来只有群聊头像/昵称是
  Link 到完整资料页,单聊完全没有交互)
- drafty.tsx:@提及实体(Drafty.mention 的 uid 存在 ent[].data.val 里,
  之前渲染完全没读这个字段),"所有人"哨兵值除外
- GroupMembersModal.tsx / GroupDetailColumn.tsx:群成员列表的行内和紧凑
  头像格子

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:45:23 +08:00
Your NameandClaude Sonnet 5 2427a3782c 群成员格子改回独立"移出"格子,点击打开成员列表,不再是逐头像角标+直接移出
用户再次纠正:不要在每个可移出成员的头像上单独加角标、点击直接弹确认移出;而是
像"+邀请"一样,做一个独立的"-移出"格子(同款虚线方块,仅 isAdmin 可见),点击后
打开"查看全部"成员列表,真正的移出操作留在列表里已有的那一行按钮上。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:28:17 +08:00
Your NameandClaude Sonnet 5 05ee23aefa 群成员格子改用头像外角标 ⊖ 移出,替换上一版的行内文字按钮
上一版把 ➖ 加在了"查看全部"弹层的成员行文字按钮里,用户纠正:应该是紧凑头像格子
(群聊设置面板里"查看全部"之前那一屏)里,像"+邀请"那样在头像外侧显示一个独立的
角标,而不是列表行内的文字。canRemoveMember 挪到 lib/api/groups.ts 共享,避免
两处判断逻辑分叉。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:19:45 +08:00
Your NameandClaude Sonnet 5 2d589cbe6b 群成员移出按钮补充 remove_circle 图标
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:58:34 +08:00
Your NameandClaude Sonnet 5 304283c8d8 fix(messages): @mentions leaked the sender's private remark
Reported + reproduced live: a group owner's own private friend remark
for a member ("小号1"/"私密备注勿外传" in testing) ended up baked into
that member's @mention text, visible to every other group member with
no idea what it referred to.

Root cause confirmed by reading server/im_http.go's userSummaryPayload:
GET .../members?mentionable=true's `display_name` unconditionally
prefers the CALLING viewer's own remark for that member when one is
set. That's correct for a list only the viewer sees, but MentionPicker
was using it to build the mention's label, which gets baked into the
message's shared Drafty content once (mentions.ts's buildMentionedContent)
and read identically by everyone from then on -- unlike a sender-name
label, which every viewer's own client re-renders fresh per message and
can legitimately show their own remark for.

Switches MentionPicker's label (both the picker's own visible list and
the mention baked into the sent message) to the same response's
`nickname` field (copyUserNickname in the same backend function),
which reads straight off the member's public profile and is never
remark-shadowed. Verified live: set a real friend remark, confirmed
the picker still showed the real nickname (not the remark), picked it,
and confirmed the actually-sent message's @mention text was the real
nickname too -- while the unrelated sender-name label elsewhere in the
same conversation correctly kept showing the remark, confirming the
fix didn't touch that intentionally-different behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:10:03 +08:00
Austin cbb01085c4 fix(reply): 修复回复消息中媒体引用的兼容性问题
适配了 Drafty 媒体的 `refurl` 字段,同时兼容旧的 `ref` 和 `val` 字段,确保不同来源的媒体链接都能正确解析用于渲染。
2026-09-23 21:37:07 +08:00
Austin a8d3504424 feat: 实现跨端引用回复协议,新增 QuoteBar 组件支持消息引用
本次提交实现了与 iOS 对齐的跨端消息引用协议,新增 QuoteBar 组件用于渲染引用消息栏,重构发送逻辑支持文本和媒体消息的引用功能。主要改动包括:
- 新增 reply.ts 处理引用数据的构建和解析
- 新增 QuoteBar 组件渲染引用消息预览
- 重构发送逻辑,将引用快照放入消息头部而非内容
- 调整代理配置指向本地开发环境
- 优化消息操作权限,支持名片和媒体消息引用
2026-09-23 20:40:47 +08:00
Your NameandClaude Sonnet 5 fb92f4f703 fix(groups): hover affordance on the remove-member button
Reported: the new "移出" (remove member) button gave no visual
feedback on hover, so it didn't read as clickable. Tailwind's own
preflight reset sets buttons to cursor:default (not the browser's
usual pointer) unless a cursor utility says otherwise -- adds
cursor-pointer + hover:underline, verified live via
getComputedStyle/:hover that both apply while hovering and correctly
reset on the disabled (in-flight) state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:27:17 +08:00
Your NameandClaude Sonnet 5 4961c0ae6e fix(groups,system): remove-member action + stale invite button
Adds removeGroupMember (DELETE /groups/{topic}/members/{user_id}) --
confirmed live the backend now has this route (server/im_http.go's
serveIMGroupMember + canRemoveIMGroupMember), which an earlier pass
recorded as a genuine protocol gap; it isn't anymore. Wired into
GroupMembersModal with a per-row "移出" button gated by the same rule
the backend enforces (owner or admin removes a plain member, only the
owner removes an admin, nobody removes the owner or themselves), with
a confirm dialog before the real DELETE call. Verified live end-to-end
against a real owned group: member count 4 -> 3, list updates
correctly (the side panel's own separate "群成员 N" summary count
still trailed behind after this -- confirmed via a direct GET that the
server's own group payload hadn't recalculated member_count yet, a
backend read-consistency lag unrelated to this fix, not chased
further).

Also fixes the reported "已处理的群邀请消息,即使群解散了,接受邀请
按钮还是一直可以点" bug: the button's visibility used to be derived
purely from the notification's static action payload every render, so
neither a successful accept nor a definitively-failed one (expired,
already handled, group gone) ever stopped it from looking freshly
clickable again. Tracks per-invitation resolved state (accepted/
failed) once either outcome happens and swaps the button for a static
resolved label. Doesn't survive a page reload for invitations resolved
in an earlier visit (the notification record itself carries no
resolved flag to read back) -- a real, acknowledged limit, not
silently hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:08:07 +08:00
Your NameandClaude Sonnet 5 6a65998e42 fix(messages): render mobile-sent image/file/voice/video attachments
Live-confirmed in a real group ("seq测试", tenant 10001) that image
messages sent from Android and iOS render as completely blank bubbles
on web, and the chat list's own preview for one showed the literal
string "undefined". Root cause confirmed by querying the actual
im_messages/im_group_messages rows: mobile apps send image/file/voice/
video as a flat, non-Drafty content object (`{file_id,
thumbnail_file_id, mime, width, height, name, size, ref?, preref?}`)
tagged only by `head['x-im-type']`, not as a real Drafty `{txt,fmt,ent}`
document the way this web client's own sender (attachments.ts) always
builds. renderDrafty/formatMessagePreview had no case for this shape
and silently produced nothing / "undefined" -- the exact same failure
pattern already fixed once for `contact_card` (see dispatch.ts's
existing contactCardContentOf), just not yet extended to attachments.

Adds mobileAttachmentContentOf (dispatch.ts), normalizing `ref`/
`preref` from `file_id`/`thumbnail_file_id` when the wire message
omits them (confirmed both cases occur on real messages), and wires it
into MessageBubble.tsx (reusing the existing Image/File/Audio/Video
Attachment components) and preview.ts (matching bracket text). Extends
VideoAttachment with a remote `previewRef` fallback alongside its
existing inline-base64 `preview`, since mobile videos' poster is a
separately-hosted file, not inline data.

Verified live against the real message rows: detection and URL
construction are confirmed correct (network panel shows the real OSS-
backed URL, exactly matching the file_id from the DB row); the actual
pixels don't load in this local dev environment because OSS's CORS
whitelist doesn't include localhost, a known, already-documented
limitation of testing via localhost instead of a real *.118im.com
domain, not a defect in this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:48:43 +08:00
Austin 0c38adba78 fix: 修复登录时因租户不一致导致的422策略违规错误
在连接建立时记录服务器实际使用的租户代码,而非直接从存储中读取,避免登录时的租户类型判断与会话租户不一致导致的TENANT_CODE_FORBIDDEN错误。新增登录失败后的自修复重试逻辑,当收到422错误且原因是租户代码被禁止时,临时覆盖登录类型并重试一次,同时重置相关状态避免影响后续连接。
2026-09-22 20:17:39 +08:00
Your NameandClaude Sonnet 5 370bf9d5b0 fix(tinode): trim tenant code before login_type comparison
Code review of the login_type fix (11276804) found tenantCode.toLowerCase()
=== 'default' doesn't trim, so a tenant code with stray leading/trailing
whitespace would be misclassified as enterprise (login_type:0) instead of
personal (login_type:1). tenant.code always comes from the resolve
response, not raw user input, so this was a latent edge case rather than
a reproduced bug -- hardened anyway since the fix is free, matching the
.trim() already used on tenant code elsewhere in this codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:42:39 +08:00
Your NameandClaude Sonnet 5 1127680425 fix(tinode): send login_type on basic-scheme login (Zentao bug #33)
server/user.go's validateLoginTenant now hard-requires a login_type
field (0 enterprise / 1 personal) on every {login scheme:"basic"}
message, rejecting the rest with a 422 LOGIN_TYPE_REQUIRED policy
violation -- confirmed live by reading the currently-deployed
server/session.go call site and server/user.go's validation logic.
The vendored tinode-sdk has no API surface for this custom field (its
login()/loginBasic() packet builders are private class fields that
only ever write scheme/secret/cred), so every basic-scheme web login
was failing.

Extends the existing installTenantHiPatch wire-level interception
(same technique already used for {hi}.tenant) to also inject
login_type into outgoing {login} packets, derived from the resolved
tenant's code. tenant_code is deliberately left unset: the backend
falls back to the session's own tenant (already established via the
{hi} patch) when the {login} message omits it, for both the personal
and enterprise branches. token-scheme logins (session restore/
reconnect re-auth) are untouched.

Verified live: captured the actual outgoing WS frame now carrying
login_type, and confirmed the resulting error changed from what would
be a 422 policy violation to a normal credential-mismatch error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:20:08 +08:00
Your NameandClaude Sonnet 5 ed029c59ef fix(tinode): auto-recover from cluster-failover topic proxy teardown
Native Cluster failover tears down a topic's server-side proxy session
on the surviving nodes without notifying the client -- the SDK's local
_attached flag is never reset on this path, so isSubscribed()/
ensureTopicAttached() keep reading a stale true and can't catch it in
advance. The server replies 409 "must attach first" to the {pub}
itself; publishWithReattach() catches exactly that, forces a fresh
subscribe(), and retries the same msg object once (safe: publishDraft's
own _noForwarding guard prevents a duplicate local echo, and the
unchanged x-im-client-mid still dedupes server-side). Wired into all 9
publishDraft call sites (send.ts, attachments.ts, calls.ts).

Confirmed live on an independent native-Cluster verification
deployment: killing the leader pod mid-conversation previously caused
every send to fail until a manual resubscribe; with this fix, 36/36
probes across the same failover succeeded with no manual intervention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 16:44:39 +08:00
Your NameandClaude Sonnet 5 74351af905 fix(messages): merge stale-sender-profile fallback into #32's uid-shape guard
Two independent fixes for the same raw-uid-leak bug landed in parallel:
upstream added groupSenderLabel() to catch uid-shaped names by regex
(covers a still-present member whose display_name IS the uid); the local
change instead resolved a left-group sender's real name via a standalone
getUserProfile() lookup (covers a member no longer in memberByUserId at
all). Neither alone covers both paths. groupSenderLabel now takes multiple
candidates in priority order -- member-list name first, then the fetched
stale-sender name, falling through to the placeholder only when neither
resolves to something that isn't uid-shaped -- so a left member with a
resolvable profile still shows their real name instead of the placeholder,
while the still-present-but-nameless case is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:50:41 +08:00
Austin ca7af8baf9 feat(acc-support): 实现动态获取并渲染联系我们条目
新增从后台获取管理员配置的联系我们数据的功能,支持邮箱和电话的跳转链接, fallback 到旧的固定链接配置
2026-09-21 13:12:56 +08:00
Austin 4dadb1bd5a fix(tinode/errors): 处理429账号锁定错误的提示信息
清理429错误消息末尾的状态码后缀,直接使用服务器返回的中文提示,无匹配时使用备用文案
2026-09-20 17:08:59 +08:00
Austin 1952f0ebcd fix(messages): never render raw uid as group sender name (#32)
Senders missing from the member list (left/removed) fell back to msg.from verbatim, and nameless members arrive from the REST API with the uid itself as display_name. Resolve both through a shared groupSenderLabel helper that substitutes a fixed placeholder for uid-shaped names (message bubbles, quoted replies, history search).
2026-09-20 16:42:35 +08:00
Austin 41728017cb Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev 2026-09-18 18:34:21 +08:00
Your NameandClaude Sonnet 5 7cf8e4f4e0 fix(groups): correct rename-sync event name and unreliable getMeta path
The previous fix listened for a documented-but-wrong event name
(group.profile.updated); live instrumentation of a real rename showed
the actual wire event is group.settings.updated. Fixing only the name
still didn't work: the refresh mechanism it triggered
(getMeta(startMetaQuery().withDesc().build())) was itself unreliable
against this backend -- live-confirmed to either time out (504) on a
brand-new topic or return stale pre-rename data on an established one,
which also explains the "second rename flips back to the original
name" symptom (a late, stale response from an earlier call clobbering
the correct value).

Replace it with the already-reliable REST getGroup() response applied
locally via the SDK's own _processMetaDesc (same internal method the
SDK calls when a real meta response arrives; confirmed to reach
me.contacts(), which the sidebar/header read).

Also fixes a second, independently-discovered bug found while
verifying the above: notifyTopic's requestAnimationFrame-based
notification batching could get permanently stuck if the callback
was scheduled while the page wasn't compositing frames, silently
freezing all future topic-list updates (not just renames) until a
full reload. Falls back to setTimeout when the document is hidden,
since that is never dropped the way the rAF was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:07:41 +08:00
Austin 0331cb099c Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev 2026-09-18 17:58:05 +08:00
Austin f4a6ff36b4 fix: realtime friend-request badge via connection-level info fallback
Server friend.request.* signals ride {info} frames addressed to the
recipient's own user id, but the SDK dispatcher resolves that topic via
its topic cache, where the me topic lives under the literal me -- the
lookup missed and the frame was dropped before any listener could see
it, so the 新朋友 badge and NewFriendsView list only updated after a
manual refresh or relogin. topics.ts now forwards frames whose topic is
not in the SDK cache (isTopicCached) through the connection-level
onInfoMessage hook to the same infoListeners used by topic-level
routing (no duplicate delivery), and AppShell invalidates the shared
['friend-requests'] query namespace on friend.request.* events, keeping
the pending badge live on any page. Zentao #3.
2026-09-18 17:42:48 +08:00
Your NameandClaude Sonnet 5 5748f1290d fix(groups): sync group rename in real time for owner and members
PATCH .../groups/{topic} pushes a custom {info what:"group",
event:"group.profile.updated"} that nothing recognized, so the
sidebar/header name (native SDK contact.public.fn) never refreshed
until an unrelated focus-triggered resync happened to touch it.
Add the missing branch to onInfo (mirrors the existing
group.dissolved/message.recalled handling) to re-pull public/desc
via topic.getMeta(...withDesc()), and trigger the same refresh from
the renamer's own save success since they won't receive their own
push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:36:11 +08:00
Your Name 0de8c0b5ac fix: message list shaking/flickering when scrolling to top or bottom
topic.onData = notify (wireTopicCallbacks) fires once per individual
message, not once per batch. Live-instrumented a real loadOlder() page
fetch (24 older messages): scrollHeight/scrollTop mutated in ~14
separate small steps over ~400ms, each one racing loadOlder()'s own
scroll-compensation and ConversationView's separate autoscroll-to-
bottom effect before the page had actually finished arriving -- that
rapid multi-step crawl is what reads as "shaking" rather than one
clean jump.

Coalesce notifications landing within the same animation frame into a
single flush, so a burst of onData calls (initial load, loadOlder(), a
fast run of incoming messages) triggers at most one re-render per
frame instead of N. messageSnapshots invalidation stays synchronous
(pure cache bookkeeping); only the listener callbacks that schedule
React's re-render are batched.

Live-verified with the same instrumentation: the same 24-message
loadOlder() fetch now produces 2 dom-mutation events instead of 14,
with scrollTop only moving once the batch has actually settled instead
of crawling in lockstep with a growing scrollHeight.
2026-09-18 17:11:40 +08:00
Your Name f5a512b8c9 fix: recall/edit not syncing to the other party in real time
docs/IM-前后端对接文档.md:416,1256 documents a custom {info what:"im",
event:"message.recalled"|"message.edited"} push to every other online
topic member, with an explicit instruction to refresh that message by
topic+seq+version rather than waiting for the user to re-enter the
conversation. Nothing recognized "im" as a valid `what` value (the
native SDK's own _routeInfo has no such case either, same gap as the
existing group.dissolved handling right above this fix), so the push
arrived over the wire but never triggered anything -- the peer's local
message cache kept the stale pre-recall/pre-edit content until some
unrelated full resync happened to refetch it.

Widen the `what` union (both the app-level RawTopicInfo and the SDK's
own onInfo callback type) to include 'im', and on that event issue the
same targeted single-message getMeta(...).withData(seq,seq+1,1) refetch
ConversationView's jump-to-message feature already established.

Live-verified: recalling a message now shows "⚠ 消息已删除" on the
other party's screen immediately, with no manual refresh.
2026-09-18 16:09:52 +08:00
Your Name 1a4a96461a feat: show group announcement read count and send read receipts
Backend added read_count/member_count/read_summary to the announcement
payload in today's deploy (previously confirmed absent). Wire it up:
- groups.ts: add the three fields to the GroupAnnouncement type.
- GroupAnnouncementModal.tsx: render "已读N/M" per README §4.8, and
  call readGroupAnnouncement() on open -- that endpoint existed in
  groups.ts already but was never called from anywhere, so opening an
  announcement never told the server this member had read it.
2026-09-18 16:05:04 +08:00
Your Name b4675b70e1 docs: correct root-cause comment on avatar CORS investigation
The OSS bucket's CORS rule does allow the app's auth headers -- just
scoped to the real web/web1-5.118im.com origins. The earlier failure
was from testing against a local dev origin, never in any CORS
whitelist, not from OSS lacking CORS entirely.
2026-09-18 15:18:32 +08:00
Your Name 74f4e753ef fix: don't route already-absolute avatar URLs through authenticated fetch
Default-avatar placeholders return avatar_file_id as a complete OSS URL
rather than a short file id. The previous fix stopped re-wrapping that
URL into a malformed /v0/file/s/https://... request, but still routed
it through useAuthedBlobUrl's authenticated fetch -- live-confirmed
that OSS bucket serves default avatars with no CORS headers at all, so
the cross-origin fetch carrying auth headers gets blocked at preflight
and the tile silently falls back to the plain letter. An absolute URL
needs no credentials; render it directly as a plain <img src> instead.
2026-09-18 15:13:27 +08:00
Your Name 7fff1a7b3a fix: call media-connect state, blocklist banner staleness, group rename, avatar URL
- stores/calls.ts, CallRoot.tsx: distinguish signaling-connected from
  actual media-joined (mediaJoined flag), so a call whose mic never
  attaches no longer shows a live timer/"网络良好"; propagate a
  disconnected reason on post-accept media failure so both sides agree
  on the outcome instead of one side seeing a normal "已结束".
- DetailColumn.tsx: invalidate conversation-context alongside blocks on
  block/unblock, so the composer's blocked-notice banner updates
  immediately instead of only after a window-focus refetch.
- GroupDetailColumn.tsx: add an owner-only group rename control
  (updateGroup was already implemented but never wired to any UI).
- Avatar.tsx: stop re-wrapping an already-absolute avatar_file_id (seen
  on default-avatar placeholders) into a malformed
  /v0/file/s/https://... request.
2026-09-18 15:01:08 +08:00
Your Name 0c5b329179 修复Zentao批量报告的6个前端bug:来电UI不弹出/黑名单发送无提示/群名校验/群人数与通话记录显示uid
用户提供了一份Zentao导出的bug清单(#21/23/24/25/26/27/28共7条),逐条实机复现
排查,修复了其中6个真正的前端bug,1个是纯后端问题(禁言不生效)已记录到
docs/IM-后端问题清单.md,1个(好友申请404/500)未能独立复现只记录了测试团队
的原始报告,另1个(离线状态推送)实测后确认并非报告描述的"永久不更新",只是
有正常的心跳超时延迟。

- **#23 来电UI完全不弹出**(根因最深的一个):initCallSignaling用的是
  `me.getMeTopic().contacts()`枚举联系人再查最新消息——但这个方法只反映`me`
  话题自己的订阅同步进度,一个P2P话题可能早就真正订阅上、能收到消息,`me`的
  同步却还没跟上,导致这个联系人在枚举里直接消失。用两个真实账号实机验证:
  被叫方`me.contacts()`只枚举出1个群,主叫方完全没出现,来电消息静默丢失。
  topics.ts里`getChatListSnapshot()`自己的注释早就写明了这个坑、并且已经改
  用`mapTopics()`规避,只是`stores/calls.ts`这个监听器当时没有跟着改。现在
  统一复用`getChatListSnapshot()`,实机验证来电卡片正确弹出。

- **#28 拉黑后发送消息无原因提示**:读tinode-sdk源码确认发送失败的具体原因
  从架构上就拿不到——`Topic#publishMessage`自己的`.catch()`把服务端拒绝原因
  吞掉、只留一个`_failed`标记,不会重新抛出。改成事前判断:新增
  `getConversationContext()`(P2P专用,`relationship`字段区分
  `blocked_by_me`/`blocked_by_peer`),发送区直接换成对应文案的横幅,两个
  方向都拿真实账号互相拉黑验证过。

- **#26 群名超长/留空自动名两个校验问题**:`MAX_TITLE_LENGTH`一直是60,但
  后端`im_http.go`真实校验的是32字(读源码确认),导致33-59字之间的群名能
  输入、能提交,然后被后端拒绝只显示通用错误。改成32,从输入框这一层就不
  再放行超长名。另外群名留空时自动拼接的成员昵称组合,理论上可能被内容审核
  拒绝——这种情况下用户根本不知道"名字"是问题所在,补了针对性提示。

- **#27 通话记录/群列表显示原始uid、群人数显示0人**(2处独立bug):
  1. `CallHistoryView.tsx`按`peer_user_id`(真实usr id)去匹配聊天列表的
     `contact.topic`(建立过真实会话后已经是服务端重命名过的p2p哈希),两个
     不是同一个id空间,永远匹配不上,一直显示原始id。改成用`item.topic`
     (已确认和chatlist的topic是同一个值)去匹配。
  2. 实测发现`GET /groups`列表接口的`member_count`会返回0,但同一个群
     `GET /groups/{topic}`单独查询正确返回4——两个接口的计数器不一致(后端
     已知问题,见问题8),前端这次统一改成"读到0就不显示人数"而不是显示一
     个已确认错误的数字。

以上改动均跑过`tsc -b --noEmit`/`oxlint`/`vite build`,除#26外全部用两个真实
账号(systest02/systest04)互相操作实机验证。
2026-09-17 21:51:00 +08:00
Your Name 50767f701f 修复已建立的P2P会话详情栏/头像404:话题名不是对方user_id
用户报告:控制台反复出现 GET /v1/im/users/p2pp77kUAkgqpVbZoiTpjPWtg/profile
404。根因:这个应用的多处代码(DetailColumn的好友资料/拉黑/备注/删除好友,以及
本会话早前刚加的头像peerProfileQuery)一直假设"P2P会话的topicId就是对方的
user_id"——这个假设只在"第一次用对方usr...地址打开、还从未真正subscribe过"
这个短暂窗口内成立。读tinode-sdk源码确认:一旦subscribe成功,SDK会把这个话题
从usr...地址重命名为服务端分配的真实p2p...哈希名,而Topic.topicType()自己的
前缀表里根本没有p2p这一项(只有usr映射到P2P类型),导致topic.isP2PType()/
topic.p2pPeerDesc()对已建立的会话恒定返回false/undefined——SDK自己内建的"取
对方身份"方法在这个场景下也是坏的,不只是这个项目自己的代码有问题。

只要一个P2P会话有过真实的消息历史(几乎所有现存对话都是这样),它的topicId
就是这个不能反解的p2p...哈希,而不是对方user_id,直接拿去查/v1/im/users/{id}/
profile、匹配好友列表的user_id、传给拉黑/删除好友接口,全部会用错id。

修复:topics.ts新增getP2PPeerUserId(topic, myUserId)——topicId本身是usr...
前缀时直接返回(还没subscribe过,本来就对),否则读topic.subscribers()真实
的订阅者列表,找出不是自己的那个人的真实user_id;订阅者列表还没加载出来时返回
undefined而不是猜一个不可能对的值,调用方原本就有的enabled门控会正确地等待
而不是提前拿错误id发请求。tinode-sdk.d.ts补充subscribers()方法和Subscription
类型声明(原来完全没声明这个方法)。DetailColumn.tsx/ConversationView.tsx改用
这个真实user_id去查资料/匹配好友/头像取色,不再直接假设topicId能用。

实机验证:真实会话grpMc5uN...不对,是p2pp77kUAkgqpVbZoiTpjPWtg(systest04↔
systest02,有真实历史消息的老会话)——修复前详情栏"ID:"整行空白、404貫穿控制
台;修复后详情栏正确显示"ID:usrp77kUAkgqpU",好友资料/备注/拉黑状态全部正确
关联到真实好友记录。
2026-09-17 12:52:44 +08:00
Your Name 425b06aa71 消息列表和聊天窗口接入真实上传头像,替换字母色块(联系人页此前已接入)
用户报告:头像上传后,联系人页面已经能正确显示,但消息列表(会话列表)和
聊天对话框(顶部头像+消息气泡头像+详情栏)仍然只显示字母色块。

根因:联系人页面(ContactsListColumn/FriendProfileView等)走的是REST
/friends、/users/{id}/profile这些接口,响应里天然带avatar_file_id字段,
接的是同一个Avatar组件。但消息列表/聊天窗口这条线读的是Tinode SDK本地维护的
话题缓存(me.contacts()/topic.public),这个对象里根本没有avatar_file_id
这个字段(那是REST专属字段,SDK的public.fn/public.photo走的是完全不同的同步
机制)——所以即使Avatar组件本身没问题,这几个位置也从来没被塞过真实的
avatar_file_id,只能落到字母兜底分支。

修复:在这几处渲染点旁路补一份轻量的id->avatar_file_id映射,全部复用已经在
发的请求(不新增网络开销):
- ChatListColumn.tsx(会话列表):群聊复用自己已有的groups查询(本来就为了
  显示"N人"徽标在发),单聊新增一个与ContactsListColumn同key的/friends查询
  (对方是好友时才有数据,走查询缓存去重)。
- ConversationView.tsx(聊天窗口顶部+消息气泡):群聊复用自己已有的group
  profile查询;P2P peer新增一个与DetailColumn同key的/users/{id}/profile
  查询;"我"发的消息复用(不再限定isSelf才拉)已有的/me查询。
- DetailColumn.tsx(P2P详情栏大头像):本来就在查/friends和/user-profile,
  只是从来没把里面的avatar_file_id接到头像上。
- GroupDetailColumn.tsx(群详情栏大头像+成员格子头像,随上一条commit一起
  提交):同样是本来就在查的group/members数据里现成的字段。

Avatar.tsx新增可选的fallbackBgClass prop——"我"发的消息气泡头像,字母兜底
色块设计上固定是强调色而不是按id哈希取色,原来内联写死bg-accent,现在通过
这个prop走统一组件不用另外复制一份渲染逻辑。

已知未覆盖(跟此前记录的已知缺口一致,本次未处理):群成员选择器/邀请弹层/
群管理弹层等曝光度更低的头像位置仍是字母色块。

重要:本次修复只是把avatar_file_id正确传给了Avatar组件——实机测试时发现
GET /v0/file/s/{id}这个下载接口目前会302重定向到一个跨域地址,
useAuthedBlobUrl用fetch+鉴权头去请求时,浏览器跟着重定向到那个跨域地址后
被CORS拦截(Failed to fetch,redirect:'manual'下确认type是opaqueredirect)。
用三个不同账号/不同file_id复测结果一致,不是个例。也就是说这条修复目前
"接线是对的",但只要这个后端问题不解决,任何地方(含联系人页、头像上传页
本身)新上传的头像实际都不会真的显示成图片,只会退回字母色块——这是后端
问题,按惯例记录不在这个仓库里修。
2026-09-16 23:37:31 +08:00
Your Name 804618ad63 修复群聊解散通知覆盖不全:主动退出/解散群聊会误报"该群聊已解散"且留下幽灵会话
前情:上一版修复只覆盖了"被动看着群被别人解散"这一种场景(后端走自定义
{info what:"group" event:"group.dissolved"}事件而非原生{pres what:"gone"},
需要单独识别)。这次用户实测发现同一个"话题彻底消失后清理不干净"的问题在另外
两条路径上都复现了:自己点"退出群聊"、自己(群主)点"解散群聊"。

根因:这两个操作各自的mutation.onSuccess只是navigate('/messages'),没有做任何
本地缓存清理;而服务端在REST调用成功后,通常还会异步地给这条已经不再属于你的
订阅补发一个原生/自定义的"gone"信号——这个信号触发的是topics.ts里那条本该只用
于"被动围观"场景的goneListeners通知链,于是ConversationView的通用"该群聊已
解散"提示被复用到了并不贴切的"我自己退出/解散"场景上,且同一个SDK缓存stub没
被清理的老毛病(getTopic()被再次调用就会重建一个空壳)也照样在这两条路径上
重演,表现为消息列表里多出一个显示原始群ID、"1970/1/1"的幽灵会话。

修复:
- topics.ts的markTopicGoneAndNotify新增goneTopicNames去重判断——只要
  forgetTopic已经先标记过这个话题,稍后到达的原生/自定义gone信号就直接跳过
  (包括不再触发goneListeners),避免重复通知。
- GroupDetailColumn.tsx的leaveMutation/disbandMutation改成onSuccess里先调用
  forgetTopic(topicId)(清缓存+加入排除名单+让上面的守卫生效)再navigate,
  不再空等异步信号来做清理。

实机验证:systest04建群拉systest02入群 -> systest02主动退出,返回消息列表后
侧栏不再残留幽灵会话;systest04(群主)解散同一个群,同样返回消息列表且侧栏
干净。顺带在GroupDetailColumn.tsx里把群头像/成员格子头像接上了真实上传头像
(见下一条commit的头像修复,这个文件因为改动集中放在了一起)。
2026-09-16 23:36:35 +08:00
Your NameandClaude Sonnet 5 df22f26628 修复刷新/深链进入已解散的群聊时显示错乱,改成直接跳回消息列表
用户实机截图报告:一个群被解散后,如果直接刷新页面(或深链)停留在这个群的
URL上,标题栏/侧边栏显示原始topic id(不是群名)、"1970/1/1"的时间戳、
"0人"成员数,而不是跳转离开。

这跟已有的"正在查看时被实时解散"处理(subscribeTopicGone/onDeleteTopic)
不是同一条路径——那个只覆盖"已经订阅成功、活跃看着的时候被解散",覆盖不了
"这次页面加载压根没订阅成功过"的情况。实机复现确认:openConversation()对
一个已解散群的{sub}请求会正确收到404 CommError拒绝,但ConversationView
原来的catch只弹一个通用的"加载会话失败,请重试"提示,不会跳转,会话视图就
停留在一个半初始化的空壳状态。

修复:新增isTopicGone()识别这个404,命中后跟"实时解散"走一样的用户体验
(toast提示+跳转回/messages)。同时发现并修复了一个连带的清理问题——单纯
调用getTinode().getTopic(name)(openConversation内部一开始就会调,不管
后面订阅成不成功)就已经在SDK本地缓存里留下一个空壳对象,光是这个空壳存在
就足以让侧边栏显示出这条错乱的记录;而清理这个空壳时又跟ConversationView
自己卸载时的closeConversation()清理逻辑(它自己也会重新调用getTopic())
产生竞态——直接删除后又被重新创建。改成维护一个"确认已消失"的名单,让聊天
列表的过滤逻辑直接按名字永久排除,不再依赖谁先执行、谁后执行。

实机验证:真实解散一个群后,直接deep-link/刷新回这个群的URL——确认页面
立即跳转回/messages,且侧边栏不再出现这条已解散群的残留记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:49:17 +08:00
Your NameandClaude Sonnet 5 c53e6552eb 补全群邀请功能的接收方一侧:系统消息里的群邀请通知现在能真正接受
追查"邀请功能不完善"时发现的更大缺口:群邀请在需要审批的情况下(群自身的
进群验证,或被邀请人自己的隐私设置要求验证)不会立即拉人入群,而是创建一条
真正的邀请记录,通过系统消息投递给对方(event_type: group.invitation.created,
action.type: group_invitation_accept,带invitation_id)——但这条系统消息
此前完全没有专门处理,走的是所有group.*事件通用的"查看群聊"跳转逻辑,直接
导航到一个对方根本还不是成员的会话,大概率打不开。

协议文档把POST /v1/im/group-invitations/{id}/accept标成"待实现",和今天
早些时候发现的草稿持久化接口是同一种文档滞后于后端实际进度的情况——读了
im_be的serveIMGroupInvitationResource源码确认这个接口真实可用。

actionRoute()现在对这个特定事件类型不再返回通用的导航链接,卡片里改成渲染
一个真正的"接受邀请"按钮(阻止事件冒泡,避免和卡片自身的整体点击-标记已读
逻辑冲突),点击后调用accept接口,成功直接跳转到这个群(这时候已经是成员
了),失败在卡片上原地显示失败原因。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:26:29 +08:00
Your NameandClaude Sonnet 5 66dad284c7 修复邀请入群交互:点击邀请要直接进入邀请,而不是先看成员列表再点第二次
用户实测反馈的真实bug:GroupDetailColumn里那个专门的"+邀请"图标(虚线边框
方块,只有canInvite为真才显示——这个权限门控本身一直是对的)点击后走的是
setOpenModal('members'),和"查看全部成员"是完全同一个入口——弹出的是成员
列表,标题栏里那个真正的"邀请"按钮还要再点一次才能看到可邀请的联系人列表。

GroupMembersModal新增initialView属性,"invite"直接以邀请视图打开(跳过
成员列表这一步),"查看全部"/"查看全部成员"两处不受影响仍然打开成员列表。

同时补全了邀请结果的反馈——原来邀请成功后只做了查询失效,无论是"立即加入"
还是需要对方/群设置审批的"待确认",界面上都没有任何区别于点击前的视觉反馈,
容易让人怀疑是不是点了没反应从而重复点击。读了im_be的serveIMGroupInvitations
源码(不是协议文档的单一示例)确认真实的单人邀请结果有joined/already_member/
pending/pending_approval/failed五种可能,现在每种都有对应的行内文案,
"failed"不计入"已处理"状态,允许原地重试。

实机验证:全新建群后点"+邀请"直接进入邀请视图(标题"邀请成员",不经过成员
列表);邀请一个全新好友后确认成功加入、群成员数正确从2变3、界面正确显示
结果反馈。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:26:12 +08:00
Your NameandClaude Sonnet 5 3fab8b08b9 被拉入新群时标题栏/桌面通知没有任何提示——扩展兜底同步机制覆盖这个场景
reconcileMeSubs()本来就能发现"此前完全不认识的话题"(今天早些时候为解决群聊
被动成员看不到新群加的兜底机制),但发现后只是原样信任服务端返回的read/unread
字段——服务端对一个刚加入的新成员,默认给的读游标经常是"视为已读"(read==seq),
如果这个群目前恰好一条消息都没有,unread就会是0,导致今天新加的标题栏滚动/
favicon角标(由同一个未读数驱动)完全没有反应,被拉群这件事本身悄无声息。

改成:对每一个新发现的话题,不管服务端给的read游标是什么,一律按"我自己完全
没确认过"处理(read清零,unread按当前seq计算);如果是群,额外通过
subscribeNewGroupDiscovered(新增的发布-订阅,push.ts订阅它去弹真正的桌面
通知"你被拉入了这个群聊",因为topics.ts不能反向依赖push.ts)触发一条独立的
桌面通知,不依赖未读数是否恰好大于0。

实机验证:systest04建一个只邀请systest05的全新群,systest05这边模拟切回
标签页触发兜底同步后,确认收到桌面通知(title/body/tag均正确)且标题栏
未读数正确增加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:25:55 +08:00
Your NameandClaude Sonnet 5 f570483b62 切换到会话时输入框自动聚焦
匹配主流IM客户端的习惯——切进一个聊天窗口应该能直接开始打字,不需要先手动
点一下输入框。只在topicId变化时聚焦一次,不依赖messages(否则每来一条新
消息就会把正在做其他操作的用户的焦点抢走)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:25:38 +08:00
Your NameandClaude Sonnet 5 3df61e9e59 接入服务端消息草稿持久化,修复切换会话/刷新页面丢失未发送草稿(禅道#19)
协议文档docs/IM-前后端对接文档.md:1003/1247把`PUT /v1/im/topics/{topic}/draft`
标注成"待实现"、把本地保存写成默认方案——但读im_be自己的源码(server/im_http.go
的serveIMTopicDraft,接的是store.Conversations.GetDraft/PutDraft)确认这个
接口其实真实可用,文档只是没跟上后端进度。之前草稿只有draftByTopic这一份纯
内存状态,没有任何持久化,刷新页面或者(取决于路由是否把ConversationView
整个卸载)切换会话都可能把草稿丢光。

实现:打开一个会话时(每个topic每个会话只拉一次)用GET拉取服务端草稿,如果
本地这个session里还没有内容才用它填充(本地正在打的字优先,不会被服务端的
旧快照打断);输入停止1.5秒后用PUT保存(带乐观锁version),不是按键就存;
切换到别的会话或整个离开消息页面时立即flush掉还没来得及等到防抖的最新内容,
不会因为切太快而丢最后几个字。加了"最后一次确认同步的内容"记录,避免"打开
会话→加载已有草稿→内容变化触发保存"这个误判成"用户编辑了"从而立刻把同一份
内容又存一遍的多余请求。

实机验证(systest04账号,因为systest01/systest02当时正被用户在真实浏览器里
测试):
- 刷新页面:输入框正确恢复之前的草稿内容
- 切到另一个具体会话再切回:正确恢复(走的是服务端往返,不是本地状态留存,
  切换瞬间有个最多一两秒的短暂加载间隙,之后正确填充)
- 输入后不到1.5秒就切走:flush机制确认立即保存,没有丢内容
- 加载已有草稿本身不会触发多余的重复保存(验证了修复前这个问题真实存在——
  版本号无缘无故多跳了一次)
- 发送消息后:本地和服务端的草稿都正确清空

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:55:55 +08:00
Your NameandClaude Sonnet 5 4bf4e75cac 修复会话开着但标签页在后台时消息被SDK自动标记已读、未读提示永远不触发的问题
用户实测报告:systest01打开着与systest02的会话,切到别的标签页后,systest02
发消息过来,systest01这边的标签页标题完全没反应。追查后发现根因比表面看到的
更深——这不是标题栏本身的bug,是更底层的已读状态被静默污染了。

读tinode-sdk源码确认:TopicMe自己的_routePres在处理服务端推给"me"话题的
`{pres what:"msg"}`通知时(这本来是让未在查看的会话也能收到未读更新的机制),
调用`cont._updateReceived(pres.seq, pres.act)`,只要`act`是假值或者等于自己,
就会无条件把`read`设成`seq`——也就是直接标记成已读。而一个只是保持"打开"状态
的会话(ConversationView挂载着、话题处于订阅状态),哪怕标签页在后台完全没人
看,一样会收到这条推送,被自动标记已读——跟标签页是否可见毫无关系。之前只
在ConversationView自己的显式noteRead()调用上做了可见性判断,根本拦不住这条
更底层、完全独立的SDK自身机制。

修复:在topics.ts里新增一套独立于SDK自身read/unread字段的"真正已读"记录
(markConfirmedRead/confirmedReadSeq),只在会话真正被看到时(打开且标签页
可见)才推进;me.onPres处理`{pres what:"msg"}`时,如果受影响的会话不是当前
正在被查看的那个,就用这份独立记录把SDK刚自动标记的已读状态纠正回去。
openConversation()打开会话时会拿当前seq给这份记录打个初始基准,但同样加了
可见性判断——直接刷新/深链到一个标签页本身就在后台的会话这种边界情况下,
不能假设"打开=看过"。activeConversationTopic的追踪从push.ts挪到了topics.ts
(两边都要用,且topics.ts不能反向依赖push.ts)。

实机验证(systest04/systest05全新账号对,因为systest01/systest02当时正被
用户自己在真实浏览器里同时测试,避免互相顶号干扰):会话保持打开、标签页
确认处于hidden状态时,对方发消息——未读数正确从0变成1,标题栏数字正确跟着
增加;随后模拟标签页重新变为可见——未读数正确清零、标题恢复正常,走完了
用户报告的完整场景闭环。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:57:55 +08:00
Your NameandClaude Sonnet 5 3d6ae089a1 修复今天新增的兜底同步会让已读会话的未读角标无故重新出现的回归
根因:tinode-sdk自己的合并工具mergeObj(utils.js)对Date类型字段有"保留更新
的值"的防护,但普通数字字段(seq/read/recv/unread)完全没有防护,谁的数据
后到就直接覆盖——读源码逐行确认。startMeReconciliation()新增的定时/切换焦点
触发的withLaterSub()增量拉取一旦命中"服务端返回的read游标还没跟上客户端刚
发出的已读回执"这种时序,就会把已经读过的会话悄悄改回未读:角标无缘无故重新
出现,且重新聚焦窗口也不会清除,因为对这个会话来说根本没有发生任何新事情去
重新触发标记已读。

修复:reconcileMeSubs()改成在发起这次增量拉取前,先给所有已知会话的
seq/read/recv/unread拍个快照;拉取完成后,对每个"拉取前就已经认识"的会话,
如果发现seq没变但unread反而变多了,直接把read/recv/unread改回快照里的值并
触发通知——只有真正全新的会话(拉取前不认识)才信任这次拉取回来的数据,这
本来就是这个兜底机制存在的唯一目的。

实机验证:monkey-patch me.getMeta,让它在真实网络请求返回后额外注入一条
过期的sub记录(模拟服务端读游标滞后),再用真实的focus事件触发
reconcileMeSubs()整条链路——确认最终状态仍然正确显示已读,而不是被污染。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:32:49 +08:00
Your NameandClaude Sonnet 5 cd6b7675d4 新消息提示:标签页标题滚动+未读数、favicon角标、桌面通知(含何时不弹的判断)
三件套,参照Slack/Telegram Web/WhatsApp Web等主流IM网页客户端的标准做法:

- lib/notifications/tabIndicator.ts:标签页被切到后台且有未读时,标题在
  "📨 (N条新消息) <原标题>"上做左向滚动,用户切回来的瞬间(visibilitychange)
  立即恢复原标题,不等下一次未读数变化才发现。favicon右上角画一个红色圆点+
  白色数字角标(canvas绘制成PNG data URL,写回现有<link rel="icon">,不是
  另加一个可能被浏览器忽略的竞争链接)。两者都由AppShell.tsx里已经算好的
  同一个unreadMessages号驱动,不另起一套可能对不上的计数。

- lib/notifications/push.ts:真正的浏览器桌面通知(Notification API)。只
  在标签页还开着(哪怕最小化/切到后台)时生效——做不到浏览器整个关掉也能收到,
  那需要Service Worker+推送订阅+后端配合,是完全另一个量级的工程。

  抑制规则("什么时候不弹"):
  · 消息来自自己(比如另一个标签页/设备同步过来的回显)
  · 正在看的就是这个会话本身(会话已打开且标签页确实可见——两者都要满足,
    一个被切到后台的标签页哪怕挂载着这个会话也不算"正在看")
  · 该会话设置了"消息免打扰"(contact.private.mute_notifications)
  · 浏览器未授予通知权限
  同一会话的连续通知用tag合并显示为一条,不会叠加刷屏;点击通知会聚焦标签页
  并跳转到对应会话(通过stores/notificationNav.ts中转,push.ts在React之外,
  没有useNavigate可用,做法与stores/calls.ts、messageJump.ts是同一个模式)。

  判断"是否是真的新消息"用的是防抖后的seq对比(防止刚打开一个从未见过的会话时,
  历史消息分批到达被逐条误判成"新消息")——第一次见到某个会话时记录基准而不是
  照单全收。**这里踩到一个真实bug并当场修复**:最初版本把"第一次见到这个会话"
  一律当成"历史消息,不通知",结果全新好友的第一条消息(对这个会话来说seq=1,
  同时也是"第一次见到")被误判成历史消息静默吞掉——用两个全新互加好友的测试
  账号实机复现后,改成额外看消息时间戳:一分钟以内的算真正新消息,无论是不是
  第一次见到这个会话,否则才当历史记录处理。

实机验证(systest01/systest02两个全新互加好友的测试账号,全部通过mock
Notification类捕获+SDK本地状态直接读取的方式验证,因为这个自动化浏览器环境
本身把Notification.permission锁死成denied,没法测试真正弹出的系统通知):
- 真实新消息:通知的title/body/tag均正确(对方昵称/消息预览文本/会话id)
- 免打扰会话:确认不弹
- 已打开且标签页可见:确认不弹
- 标题栏滚动+favicon角标:实机确认能正确开始/停止滚动、favicon生成的是
  真实可解码的64×64 PNG

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:14:12 +08:00
Your NameandClaude Sonnet 5 8a593f50c3 系统消息:无跳转链接的通知永远停留在"新",改为整张卡片可点击标记已读
readMutation.mutate()此前只挂在"查看XXX"跳转按钮的onClick上,而这个按钮只在
actionRoute()能解析出目标时才渲染——好友申请已通过/拒绝、多数群聊动态类通知
根本没有可跳转目标,导致整张卡片没有任何东西可点,无论过多久都停留在"新"。

改为整张卡片本身是点击目标:有action的card点击后标记已读+跳转,没有action的
只标记已读。原来的"查看…"跳转按钮改成纯文本span(它现在处于卡片自己的点击
区域内,再嵌一层可交互元素会构成非法的按钮嵌套/双重触发,与之前修过的收藏夹
语音播放器按钮嵌套是同一类问题)。

实机验证:一条"好友申请已处理"(无action)点击后"新"角标消失;一条"新的好友
申请"(有action)点击后正确标记已读且跳转到/contacts/requests,无回归。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:13:41 +08:00
Your NameandClaude Sonnet 5 ab3843dbdb 群聊生命周期事件对被动成员实时不可见——前端兜底同步(真正的根因在后端,无法单靠前端代码彻底解决)
读im_be的server/im_group_events.go确认:群聊创建/加人/踢人/解散/转让群主等全部事件都走
publishIMGroupRealtimeEvent,这个函数只会广播给"当前已订阅该群topic的会话"——对比P2P场景专门
有一条deliverIMConversationSystemMessageToP2PSessions,会无视订阅状态强制投递给双方的session,
群聊从未做过同等实现。实机验证:一个全新创建、成员从未打开过的群,即使对方账号完全在线、页面就
停留在/messages上,群条目和未读角标都不会出现,等多久都不会自愈,必须手动刷新页面。

由于这个topic客户端本来就不知道存在,没有任何东西可以"响应",前端无法真正修复,只能缓解:复用
subscribeMeTopic()/onReauthenticated()已经在用的withLaterSub()增量语义,改用getMeta()(不像
subscribe()那样在_attached为true时直接短路)定时(30秒)+ 切回标签页时主动拉一次"me"话题的最新
订阅列表。

实机验证:创建一个全新群、只邀请一个从未见过这个群的被动成员,在其页面保持被动、不做任何操作/
刷新的情况下——模拟切换标签页触发的focus/visibilitychange事件后约十几秒内,群条目正确出现在
列表顶部;另一次未受干预的测试中,纯靠30秒定时器本身也确认能自愈。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:55:04 +08:00
Your NameandClaude Sonnet 5 d1f3aea26e 群聊消息记录中点击发送者头像/名字可跳转查看详情
isGroup且非本人消息、非多选态时,头像与名字改为Link跳转到/contacts/{msg.from},
其余情况(含选中模式,避免中断正在进行的批量选择)保持原有纯展示span不变。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:03:25 +08:00
Your NameandClaude Sonnet 5 8431f83ede 修复从好友资料页"发消息"直接进入新P2P会话时名称/在线状态永久缺失的问题
根因比此前提交(ff69e454)修复的isCommType()问题更深一层:即使会话能正确
出现在列表里,openConversation()走的withLaterDesc()增量查询在这条特定
路径(从未经过整页刷新,纯SPA内跳转)下会订阅成功但不返回desc/presence,
topic.public永久为null、在线状态永久为"离线",等待再久也不会自愈——整页
刷新之所以从未复现过这个问题,是因为subscribeMeTopic()自己的全量重新
订阅逻辑无条件覆盖了这个缺口。

改为在topic.public缺失时,用me.getMeta(withOneSub(topic))主动拉取——这
是SDK自己的presence路由内部用于"刚得知这个话题"场景的同一个调用,一次
返回desc+在线状态两者,且me话题的onMetaSub/onSubsUpdated回调早已在登录
时挂好,响应到达后会自动使聊天列表失效重新渲染。

已用全新测试账号对(systester9+systester2)在开发服务器实机验证:通过
真实DOM点击(非navigate()整页刷新)从通讯录进入全新会话,标题栏与侧边栏
立即正确显示对方名称与"在线"状态。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:03:18 +08:00
Your Name c455644cca NavRail左上角头像补充点击跳转个人资料页
此前是纯展示的Avatar,没有包任何可点击元素。包一层Link指向
/settings/profile,和设置页里"个人资料"入口是同一个目的地。
2026-09-16 16:58:21 +08:00
Your Name 823a47e182 修复群公告发布通知消息正文完全不显示、聊天列表预览显示"undefined"
群公告发布后自动生成的通知消息,真实content是{text:"群公告\n<内容>"}这种
扁平对象(不是纯字符串,也不是标准Drafty的{txt,fmt,ent}结构),且
head['x-im-type']是"text"而不是已处理的"system_notice",落到Drafty处理
分支后既渲染不出气泡正文、列表预览也变成字面的"undefined"字符串。

抽出dispatch.ts里原本只服务system_notice的{text}扁平内容识别逻辑
(重命名为flatTextContentOf并导出),在renderDrafty和
formatMessagePreview的Drafty分支之前都接上——不改变已有的居中系统提示
样式(这条消息本来就该是普通气泡,发送者信息一直是正确的,只是正文
丢了),只是把"文本内容是扁平{text}对象"这一种情况也当作合法纯文本处理。

实测:真实发布一条群公告后,气泡正文和列表预览都正确显示"群公告
全功能测试公告内容",不再是空白或"undefined"。
2026-09-16 16:37:48 +08:00
Your Name 92892e3e7b 修复两个严重崩溃:群管理页面查询键冲突导致整个会话崩溃、禁言列表读取
不存在的嵌套字段崩溃

- ConversationView.tsx和GroupAdminModal.tsx对群禁言列表用了完全相同的
  React Query缓存键['group-mutes', topicId],但分别调用getGroupMutes
  (单页,{items:[]}包装对象)和getAllGroupMutes(全部页,展开后的纯数组)
  两个返回形状不同的函数。两个组件同时挂载时后写入的一方会覆盖缓存,
  另一方读到错误形状后对undefined取.length直接崩溃,导致只要打开某个
  群的"群管理"面板整个会话界面就白屏。改成两处统一用getAllGroupMutes,
  真正共享同一份、同一种形状的数据
- GroupMute类型声明的是嵌套的user:{user_id,display_name},但真实响应
  是扁平的{user_id,muted_by,muted_at,reason,status,version},且完全没有
  display_name字段。这个字段错配从来没被暴露过,因为此前从未有真实账号
  真正禁言过人后去看这个列表——本轮实测"禁言→查看列表"时才第一次真正
  用真实数据渲染,立刻崩溃。改成扁平字段+交叉查群成员列表解析真实姓名
  (同一模式ConversationView.tsx的memberByUserId、GroupFilesModal的
  uploader_id都已经在用)

用全新账号在全新群里完整实测:禁言成员1人正确显示头像+姓名"systester2"
(不再是原始uid或崩溃),解除禁言后列表正确清零,两处此前必崩的入口
(群管理面板本身、禁言列表渲染)全部验证通过。
2026-09-16 16:23:06 +08:00
Your Name 8013c1675f 修复禅道Bug清单中的前端问题:好友申请source校验、群解散二次验证缺失、
群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示

- 好友申请首次必失败30000(P0):AddFriendModal把手机号搜索单独标记为
  source:"phone"上报,但后端serveIMFriendRequestCreate的source校验只认
  search/qr/group,命中default分支返回"invalid friend request source"。
  改为手机号搜索也统一上报source:"search"(后端本就不区分具体搜索渠道),
  用两个全新、此前无任何关系的测试账号验证首次申请200成功
- 解散群聊需要短信二次验证但完整流程从未实现(P0):新增
  SecurityChallengeModal通用二次验证弹层(POST /security/challenges→
  验证码→verify→action_token)+ requestActionToken()(stores/
  securityChallenge.ts,与requestConfirm同一模式),接入解散群聊和转让
  群主(后者此前也因缺X-Action-Token必然404/400)。同时发现并修复
  imDelete对无body请求不显式传空对象体,导致携带Content-Type:
  application/json但请求体真正为空时后端JSON解码失败返回400——实测确认
  两个问题都解决后DELETE返回200
- 群聊被解散但当前正查看该群的用户界面不会跳转(P1):新增
  topics.ts的subscribeTopicGone()监听SDK自身的onDeleteTopic回调,
  ConversationView据此在当前会话被解散时弹出提示并跳转回消息列表
- P2P会话发起方标题栏显示原始topic id、对方明明在线却显示离线:确认
  为上一轮已修复的isCommType()根因问题的同一症状,本轮实测(新建从未
  聊过天的账号对)已不再复现;群内消息发送者显示原始uid的关联描述同样
  实测未复现
- 好友关系冲突(已是好友/申请正在处理中)报错英文原文透出:翻译表补充
  FRIEND_REQUEST_CONFLICT
- 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会
  立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正
  超时后才显示该链接,与其上方的提示文案保持一致的显示条件
2026-09-16 15:42:41 +08:00
Your Name ff69e4548c 修复P2P会话完全不出现在聊天列表、标题栏显示原始ID+离线的根因
根因:tinode-sdk自带的Topic.topicType()前缀表只认'usr'为P2P类型前缀,没有
'p2p'这个条目,而本后端真实分配的P2P话题名是`p2p<hash>`形式——导致
isCommType()对每一个P2P会话都恒定返回false,MeTopic.contacts()因此把所有
单聊会话过滤掉。用真实账号实测确认这不是新会话/顶号账号特有的问题:任何
账号的任何P2P好友关系都受影响,只是群聊和"slf"自聊天不受影响所以此前一直
没被发现。

聊天列表改用Tinode#mapTopics()(不做类型过滤的原始遍历)+ 本项目自己正确
的前缀判断(p2p/grp/slf),不再依赖SDK自身的isCommType()。

实测(e2etester1/e2etester2真实新建好友关系,此前从未聊过天):修复前
标题栏显示原始topic hash+"离线"(对方实际在线)、侧边栏"暂无会话"(消息
其实已正确送达,只是不可见);修复后标题栏正确显示对方昵称+"在线",侧边栏
正确出现该会话及消息预览。
2026-09-16 13:41:26 +08:00
Your Name 29a1f1c2fd 修复收藏/系统消息/通话记录批次的代码审查问题
- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝")
- 通话记录页补充移动端返回头部与加载失败重试态
- 挂断时按是否已接通传递正确的挂断原因(cancelled)
- 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数
- 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式
- 收藏卡片边框/分类文字/侧栏图标颜色token修正
2026-09-16 12:54:40 +08:00
Your NameandClaude Sonnet 5 97e8d7144f fix(web): 第9轮审查批次4——设置域一批问题
- 头像上传从未带purpose=user_avatar:之前是照抄一个已经修复的旧后端
  bug(问题28)绕开的写法,现在确认接口早就支持这个purpose了,补上——
  uploadFile()新增可选purpose参数,不影响其它已有调用点(聊天附件/群
  文件继续用topic的chat_file路径)
- 桌面卡片行分隔线6处错用了移动端专属的border-list-item token,改成
  桌面正确的border-row
- 退出登录按钮外边距/设置目录导航图标尺寸(18px→20px)与设计稿对齐
- 地区字段补上maxLength兜底
- 修改密码没有用<form>包裹,输入框按回车没反应——补上表单+onSubmit
- 记录一条此前没进清单的后端缺口:"加我为好友时需要验证"这个隐私开关
  设计稿画了,但响应体里根本没有对应字段

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:46:48 +08:00
Your NameandClaude Sonnet 5 18d21a03bb fix(web): 第9轮审查批次3——通讯录域一批问题
- 手机号搜索加好友时来源永远上报"search"(账号搜索),NewFriendsView
  早就有phone的文案映射但一直用不到——现在按searchType正确区分
- 添加好友弹层三个快捷按钮布局改成横向(原来是纵向堆叠,和设计稿方向
  不一致),图标颜色改成强调色
- "已选择"状态标签的圆角从胶囊改成6px小圆角,和同弹层其它按钮风格一致
- 通讯录头部"加好友"按钮补上移动端尺寸(40px/桌面36px),之前和旁边
  FIXED_ENTRIES的断点处理不一致
- 我的二维码/名片二维码生成失败没有重试入口,只能关掉弹层重开——补上
  重试按钮,同时处理"铸造失败"和"canvas绘制失败"两种情况
- 通讯录域几乎所有列表(好友列表/新朋友/群聊列表/黑名单/好友主页/加
  好友搜索)都把请求失败误判成"没有数据",统一补上isError态+重试按
  钮;好友主页额外区分403(资料保护)/404(真的不存在)/其它错误三种情况,
  之前网络错误也会显示"未找到该用户"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:41:20 +08:00
Your NameandClaude Sonnet 5 8f4b0de636 fix(web): 第9轮审查批次2——群聊域一批问题
- 建群留空群名字直接被后端拒绝:placeholder写的"未填写时默认为成员
  昵称组合"从未真正实现,现在留空时用已选成员昵称拼接生成默认名,
  按MAX_TITLE_LENGTH截断
- 群文件上传按钮不受"群文件上传权限"治理开关约束:新增canUpload
  (和canInvite同一个算法),仅管理员时非管理员看不到上传入口
- 群管理弹层5个治理开关(进群验证/全员禁言/文件上传/私聊权限/邀请
  权限)都没有读取后端返回的对应_editable字段,现在全部接上
- 禁言成员列表只拉第一页:新增getAllGroupMutes复用fetchAllPages,
  和getAllGroupMembers同一个模式,人数多的群不会再静默截断
- 添加管理员没有前端数量上限校验:达到上限时按钮禁用并提示"已达
  管理员人数上限",不再让用户选完人才被后端拒绝
- 清空聊天记录没有错误处理,失败时静默无提示,现在补上

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:37:46 +08:00
Your NameandClaude Sonnet 5 ab96ae1fee fix(web): 第9轮审查批次1——认证/图标CSS/顶号竞态/消息核心一批问题
认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
  返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
  发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
  myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
  就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
  token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
  不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
  min-[900px]:block hidden
- 企业码输入框补maxLength=64

图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立

顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑

通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底

消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
  复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
  的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
  isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
  ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
  token),改成语义正确的unread token;录音条文字被连带染红,只保留
  指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
  不是text-tertiary-3)都和设计稿不符,已修正

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:33:23 +08:00
Your NameandClaude Sonnet 5 fb1fda689c fix(web): 图标尺寸覆盖(text-[Npx])在任何地方都不生效——CSS层级冲突
根因:Material Icons的Google Fonts样式表(index.html的<link>)自己的
.material-icons规则完全没有@layer包裹(纯普通CSS),而Tailwind v4的
每一个工具类都在@layer utilities里——CSS Cascade Layers规范规定,任
何未分层的声明,不管选择器优先级多高、样式表加载顺序多晚,永远优先于
任何分层声明。实测坐实:<Icon className="text-[14px]">的计算样式恒
定是font-size:24px,不管试图覆盖成几px都没用,因为Google那条未分层
规则永远赢。这不是某一处的问题,是Icon组件全局的、此前从未被发现的
系统性bug——历史上多轮视觉审查用截图核对布局,没人具体去量过某个
图标的computed font-size。

修复:Icon.tsx改用自己的im-icon类名,不再用material-icons这个
Google样式表实际瞄准的类名,这样那条未分层规则从一开始就不会匹配到
这些元素。index.css里用@layer base原样照抄了Material Icons自己那条
规则的视觉默认值(24px/line-height:1等),套上分层之后,Tailwind自己
的@layer utilities(在Tailwind的层注册顺序里排在base之后)就能正常
覆盖它了——和每一处写`text-[Npx]`时本来就该有的效果一致。

全局仓库搜索确认material-icons这个类名只有Icon.tsx一处引用,改动范围
可控、不涉及其它代码路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:51:16 +08:00
Your NameandClaude Sonnet 5 776131b1de fix(web): 全新好友关系不会立即出现在会话列表/正确显示在线状态
上一条commit修的是"消息本身发不出去",这条修的是同一个根因链条上更早
一环:SDK只对`me`话题的onMetaSub/onSubsUpdated两个回调做了这个"联系
人变化了"的通知(读tinode.dev.js确认:无论是哪个话题触发的订阅,SDK
永远只调用me.onMetaSub/me.onSubsUpdated,不是触发订阅的那个话题自己
的回调),但这个项目的wireTopicCallbacks只接了onSubsUpdated,漏了
onMetaSub——这正是brand-new P2P话题从usr别名被服务端改名成真实
p2p...哈希的那一刻,SDK唯一会触发的两个回调之一。

现在两个回调都接上、都指向同一个notify(),会话列表和ConversationView
自己的contact查找(决定头部显示真实昵称还是裸id、在线还是离线)应该
能在好友关系刚建立、还没有任何一方发过消息时就正确更新,不用等一次不
相关的全量重连才被动捡回正确状态。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:47:50 +08:00
Your NameandClaude Sonnet 5 0824502ddd fix(web): 全新单聊(usr别名)会话消息收不到/在线状态错误——URL没跟上SDK重命名
真实两账号复现:好友刚加上、双方都还没发过消息的单聊,发消息表现为"发了
和没发一样"(无失败提示,但对方收不到),会话头部同时显示错误的裸ID和
"离线"(即使对方明明在线)。

根因(读tinode-sdk真实源码+原生协议脚本交叉验证,非猜测):这类会话从
"对方user_id"(usr...)这个地址进入,服务端订阅成功后会返回一个不同的
真实内部话题名(p2p...哈希)。SDK的Topic#subscribe在topic对象处于
`_new`状态时(每个新建对象默认如此)会把本地缓存从usr...改名到这个真
实名字——但本组件的submit()/头部展示/MessageBubble的retry()全都各自
独立调用`tinode.getTopic(topicId)`,用的是URL里那个没变过的usr...字符
串。改名发生后再调用`getTopic('usr...')`是一次缓存未命中,会凭空造出
一个全新的、从未真正订阅过的"替身"对象——发送/已读/在线状态全部作用在
这个空对象上,而真正收到数据的是已经改名的那个对象,两者从此互不相干。

用一个独立的原生WS协议脚本验证了根因:直接订阅+发布到同一个usr别名,
服务端确认消息真的送达并持久化(用一次全新订阅的历史记录能拉到),证
明"消息发不出去"是假象,真正的问题在于客户端读的从来不是收到数据的那
个对象。

修复:openConversation()解析出的topic对象一旦.name和URL参数不一致
(即刚刚被服务端改名),立刻用real name替换当前URL(replace,不留历
史记录)。这样一来,本组件后续所有`getTopic(topicId)`都会读到同一个、
真正接了收数据的对象,而不是每次都对着URL里那个过期字符串重新变出一
个空壳。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:43:56 +08:00
Your NameandClaude Sonnet 5 9adadaa57d feat(web): 实现强制下线通知与顶号互踢的客户端感知(Ctrl 205 evicted)
按docs/强制下线通知与顶号互踢-后端接口文档.md §2实现。该文档§2.4声称
"SDK侧已内置:收到后关闭autoreconnect并回调onKick(reason, ctrl)"——读
实际安装的tinode-sdk(0.25.3)真实源码确认这个说法不成立:SDK对
code:205/text:"evicted"只有topic级的内部处理(topic._resetSub()),账
号级(topic为空)完全没有特殊逻辑,也没有onKick这个回调。

改为用SDK真实存在、此前项目里完全没用过的onCtrlMessage钩子(每条
{ctrl}包都会触发,含账号级的)在connection.ts里自行判定
"code===205 && text==='evicted' && !topic"。检测到后立即:清空
sessionStorage的token、清空myUserId、调用resetTinode()——其内部的
disconnect()会把SDK自己的#boffClosed标记置真,这是唯一能真正阻止
backoff计时器安排下一次重连的方式(读真实bundle的Connection#init_ws
确认,不存在另外一个"关闭autoreconnect"的方法)。只有面向用户的弹窗
和跳转延后到用户点确定,逻辑上是"会话立刻终止,通知是补充说明",不
是"通知完才终止"。

新增KickedNoticeRoot(挂载在App.tsx顶层,和ConfirmDialogRoot/
GlobalToastRoot同一层级,保证任意页面被踢都能弹出),文案原样照抄文
档§2.4的表格(reason→标题/正文),单一"确定"按钮(不是取消/确定二
选一——已经被踢了,没有"取消"这个选项,给出会误导)。确认后用
window.location.href硬跳转/login,和client.ts的forceReauth()同一个
理由:保证内存里的每一处状态(TanStack Query缓存等)都从头重置,不用
指望每个组件各自正确响应"你已经被登出了"。

topic非空的205(群内移出等topic级事件)不受影响,走原有的SDK内部
处理,不弹这个全局提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:13:28 +08:00
Your NameandClaude Sonnet 5 c3e8f91364 fix(web): 修复自聊天显示为原始topic id、主发送/重试路径未重新订阅话题
自聊天(slf)会话头部/侧边栏/聊天列表此前直接回退显示原始topic id "slf"
(而不是自己的昵称),且会话头部还错误显示了通话/视频按钮和"离线"状态——
这三者都源自同一处:peerName的计算只查好友列表(不含自己),没有对slf
做特殊处理。DetailColumn此前已经用isSelf正确处理了详情栏内部的各项操
作(commit 9271a0a9),但ConversationView自己的头部渲染和ChatListColumn
的列表行都没有跟进。改为isSelf时读取AppShell/SettingsLayout已经在用的
同一个['me']缓存查询,取自己的真实昵称,并隐藏通话按钮和在线状态行。

真实两账号联调时发现更严重的问题:主输入框发送消息(ConversationView
submit())和失败重试(MessageBubble retry())都直接调用sendText/
retryMessage,从未像转发流程(见ensureTopicAttached自己的注释)那样先
确保话题处于attached状态——连接抖动一次(断线重连会重置每个话题的
attached状态)之后,不管是正常发送还是点击重试都会直接失败,不会自
动恢复。转发流程本来就有这个防护,这次给主发送路径和重试路径补上同
一个防护。

用两个真实测试账号(asn494180 与新建的e2etester1,跨web.118im.com/
web2.118im.com两个域名分别登录)完整验证:加好友、接受好友申请、
P2P发消息全链路,复现问题后确认修复生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:44:19 +08:00
Your NameandClaude Sonnet 5 e78437cb2a fix(web): upgrade http OSS URLs to https to fix mixed-content block
Aliyun OSS sometimes returns presigned URLs as http:// even though every
page here is served over https:// -- the browser silently blocks these
as mixed content, which is why images (e.g. avatar) failed to display
after upload. Added upgradeToHttps() and wired it into fileDownloadUrl()
(the single chokepoint every image/attachment fetch in the app resolves
through) plus the three chat-attachment send paths, so both freshly
fetched and newly-persisted URLs get the scheme fix.

Also fixed ProfileSettingsPage's avatar upload handler, which extracted
the file id by parsing the upload response's `url` with .split('/').pop()
instead of just reading the `file_id` field the response already
provides -- fragile against exactly this kind of full-URL response and
the most likely actual trigger for the reported bug.

vite.config.ts: dev server port now respects $PORT if set, falls back
to 8094 -- unrelated cleanup, needed it to run a second local dev
server for verification alongside another concurrent session's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:18:31 +08:00
Your NameandClaude Sonnet 5 70cc977e16 fix(web): 左上角自己头像接入真实数据,会话订阅失败不再静默吞错
NavRail 的自己头像此前是硬编码的"我"字,AppShell 从没往下传过真实的
user_id/nickname/avatar_file_id,所以无论是谁登录、有没有上传头像都不会变;
现在改为拉取 /v1/im/me 并用 Avatar 组件渲染,和设置页/联系人等处一致。

ConversationView 打开会话失败时原来是 .catch(() => {}) 整个吞掉,用户会看到
空白的消息列表且没有任何提示;由于已读回执只在拿到消息后才会发,未读角标
也会因此永久卡住。现在失败会弹出错误提示,不再静默。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:28:53 +08:00
Your NameandClaude Sonnet 5 d5fc03db0e ci: retarget deploy workflow at the k3s test cluster
Old VM's blue-green im-webapp is decommissioned; build+push now
targets the mutable :test tag the cluster's kustomize overlay
already tracks, then rolls the k3s deployment instead of running
/opt/apps/im-webapp/deploy.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:40:03 +08:00
Your NameandClaude Sonnet 5 f9d4d48148 fix: kill zombie pre-rewrite service worker
The old service-worker.js (repo root, removed when web/ was rewritten)
cached every same-origin GET keyed only by frontend PACKAGE_VERSION, so
a backend-only change never invalidated it. Browsers that had visited
this origin before the rewrite kept getting stale cached API responses
(bootstrap/session calls) served instead of hitting the real backend --
looked like "login succeeds then immediately bounces back to login".

Adds a same-path kill-switch service-worker.js that purges all caches
and unregisters itself, plus an explicit update() check on app boot so
already-registered browsers don't have to wait for the browser's own
lazy update schedule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:27:49 +08:00
Your NameandClaude Sonnet 5 37d08e8989 feat(web): 用符合系统风格的组件替换全部 window.alert/confirm
全项目搜出15处 window.confirm、9处 window.alert(其中3处是各文件
各自复制的 notImplemented() 辅助函数,文案还都一样)用在退出登录、
删除消息/收藏/好友、清空聊天记录、解散群聊、转让群主、危险链接
二次确认等场景——全是浏览器原生弹窗,样式和整个应用的设计语言完全
脱节。

新增两个全局UI基础设施(Zustand store + 挂载在App.tsx根节点的
渲染组件,参照stores/calls.ts/messageJump.ts已有的"任何组件都能调用"
惯例,不是每个弹窗各自管理一份本地状态):

- stores/confirmDialog.ts + app/ConfirmDialogRoot.tsx:替换
  window.confirm。requestConfirm()返回Promise<boolean>,和
  window.confirm的返回值语义一致,所以原来"if (!window.confirm(...))
  return"这一行改成"if (!(await requestConfirm({message:...})))
  return",外层函数标成async,不用大改周围逻辑。危险/不可恢复操作
  (删除、解散群聊、注销账号等)传danger:true,确认按钮变红色,
  弹窗视觉沿用转发弹窗已有的"描边取消+实心确认"按钮搭配。
- stores/globalToast.ts + app/GlobalToastRoot.tsx:替换window.alert,
  复用应用里本来就有、每个页面各自本地状态管理的Toast组件。顺手把
  3个文件里各自重复定义、文案完全一样的notImplemented()合并成
  一个共享导出。

实机验证了非危险确认(清空缓存,蓝色确认按钮)、危险确认(删除
消息,红色确认按钮)、取消按钮、Escape键取消、全局提示(点"投诉
举报")——均正常工作,且原有页面各自的Toast错误提示不受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 00:08:15 +08:00
Your NameandClaude Sonnet 5 76156a1c75 fix(web): 点表情后光标不再跳出输入框,需要重新点击才能回车发送
点表情面板里的emoji这个操作本身没有做"点完之后把焦点还给输入框"
这件事——emoji按钮是个真实的<button>,点击后浏览器会把焦点停在
这个按钮上,导致输入框失焦,这时候按回车自然没反应(键盘事件根本
没送到输入框),要用户重新点一下输入框才能继续打字/发送。

参照"@"提及按钮已有的"点完之后把焦点和光标位置还给输入框"这套写法
(insertMentionTrigger/pickMention),给emoji选择也接上同一套逻辑,
并且改成插入到光标所在位置,而不是永远追加到文本末尾。

顺手把这三处(含原来的两处"@"相关逻辑)从 requestAnimationFrame
换成 setTimeout(fn, 0):实测确认 requestAnimationFrame 在页面处于
后台/不可见状态时完全不会触发(浏览器的标准省电行为),而这个"点击
按钮后把焦点还给输入框"的操作没有理由依赖页面必须处于前台可见状态
才生效,setTimeout 不受这个限制。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:49:57 +08:00
Your NameandClaude Sonnet 5 4e1f1cbf0f refactor(web): 提取共享的圆形多选勾选框组件,统一5处重复实现
创建群聊、消息多选、我的收藏多选、黑名单多选、转发选择器——这5个
地方各自手写了一份几乎一样的"圆形+选中变实心+里面check图标"勾选框
markup,样式上有细微且无意的差异(比如有的选中态多画了一圈边框、
用的灰色深浅不完全一致),上次只修了转发选择器一处,用户指出创建
群聊那里同样丑、而且问了"这种组件不该统一做吗"——確实该統一。

新增 `app/SelectionCheckbox.tsx`,把上次在转发选择器里验证过的
矢量勾选图标方案抽成共享组件,5处全部换成调用它。以后这个视觉样式
要再调整,改一个文件就够了,不会再出现"修好了一处,另外四处还是
老样子"的情况。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:47:08 +08:00
Your NameandClaude Sonnet 5 3280b92a09 fix(web): 转发选择器勾选样式改用矢量图标,好友/群聊可分别展开收起
原来的勾选图标是Material Icons字体里的"check"字形,在20px圆圈里塞
14px字体图标,字体本身没有针对这么小的尺寸做过精细调校,稍微放大
一点看就是个不规整的糊状图案。换成手绘SVG勾选路径(矢量、任何缩放
比例下都清晰),并去掉了选中态多余的一层border(原来选中时背景色
和边框色叠在一起,等于多画了一圈没必要的描边)。

好友、群聊两个分组标题现在都可以点击收起/展开(各自独立),列表长
的时候可以先收起不relevant的那一类,收起状态下已经选中的项不会被
清空,"全选"按钮无论展开收起都能点。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:25:36 +08:00
Your NameandClaude Sonnet 5 e3217a5de9 fix(web): 转发选择器好友/群聊分开全选
原来是列表整体一个全选按钮,选中后好友和群聊会一起被勾上。改成
好友、群聊两个分组各自独立的全选/取消全选,互不影响,方便只想
批量选中其中一类的场景(比如只发给所有群,不带上私聊好友)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:15:29 +08:00
Your NameandClaude Sonnet 5 46b3288880 feat(web): 下线自主注册与协议勾选,修复转发消息报错,转发选择器分组+全选
按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由
及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件
Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等
死代码;登录不再依赖勾选协议才能提交。

修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/
`sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未
真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正
打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过
预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何
一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时
订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开
过的好友/群转发均成功。

转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个
列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:04:59 +08:00
Your NameandClaude Sonnet 5 1826d2906f feat(web): 会话头像/名称可点击展开详情栏,移除"关于 MeetingIM"设置页
聊天窗口头部的头像和昵称此前只是静态展示,只有右侧"i"图标能展开
详情栏;现在点击头像或名称同样会触发 onToggleDetail,单聊群聊均
生效。

按产品决定移除设置里的"关于 MeetingIM"页面(桌面目录、移动端"我的"
二级设置列表两处入口一并去掉),该页面唯一内容是版本号和一个后端
本就不支持 platform=web 的检查更新按钮,删除后 AboutPage.tsx 和
仅被它使用的 lib/api/app.ts 一并清理为死代码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:06:46 +08:00
Your NameandClaude Sonnet 5 9271a0a9c1 fix(web): 隐藏文件助手入口,修复自聊天详情栏错误操作
后端目前没有支持文件助手所需的配额/分类等能力,只是把它当作
普通话题处理;与其做一个功能不全的界面,先从导航栏、会话列表
置顶区、通讯录固定入口、设置页移动端快捷入口中移除该入口。slf
话题现在按普通会话行为处理(排序、未读计数均不再特殊处理)。

同时修复 DetailColumn 对自聊天(slf)显示语音/视频通话、备注、
拉黑、举报、删除好友等对自己无意义操作的问题,改为仅显示适用
的发消息/查找聊天记录/置顶/免打扰/清空聊天记录等项。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:35:58 +08:00
Your NameandClaude Sonnet 5 3065e232be fix(web): favicon didn't match the app's real logo mark
public/favicon.svg was a leftover scaffold placeholder -- a generic purple
gradient blob, unrelated to this app's actual brand mark (a plain #1677ff
rounded square with a white "M", as literally rendered on the login/
tenant-entry screens: `bg-accent` + bold white "M"). Replaced it with an
SVG matching that exact design so the browser tab icon is recognizably the
same mark as the rest of the app.

Also removed public/icons.svg while in here -- a sprite sheet of generic
social-media icons (bluesky/discord/github/x) from the same leftover
scaffolding, confirmed unreferenced anywhere in the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:18:30 +08:00
Your NameandClaude Sonnet 5 d1c81d432e fix(web): 文件助手 duplicated as its own chat-list row once slf has real history
文件助手 is a permanent pinned entry that redirects into a real conversation on
the slf topic (see PINNED_ENTRIES's own doc comment -- there's no dedicated
file-browser API to back a real one). Once that topic has any message
history it also comes back from useChatList() as an ordinary contact, so it
rendered a second time as its own chronologically-sorted row further down --
confirmed live: navigating to /messages/slf via either row satisfied both
rows' own "am I active" check at once, highlighting both simultaneously.

Filters the slf topic out of the ordinary contacts list; 文件助手 stays the
one and only way it's represented there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:01:50 +08:00
Your NameandClaude Sonnet 5 db0f8e4543 fix(deploy): build/serve the new web/ Vite app instead of the old webpack build
The deploy pipeline (.gitea/workflows/deploy.yml -> Dockerfile) never got
updated when the frontend was rewritten under web/ -- it was still doing
`npm ci`/`npm run build` at the repo root and copying the old app's umd/,
css/, index.html, etc. into the nginx image. Every deploy therefore kept
shipping the old app regardless of what was actually on dev, no matter how
much of the rewrite had landed.

Dockerfile now builds from web/ (Vite) and copies its self-contained
dist/ output (index.html + content-hashed assets/, no separate umd/css/
img/audio/manifest/service-worker/firebase files -- none of that exists
in the new app). VITE_TINODE_API_KEY is what src/lib/config.ts actually
reads (Vite only exposes VITE_-prefixed env vars to client code), so the
existing TINODE_API_KEY build-arg is now renamed into that on its way
into the build environment instead of passed through under its old name.

nginx.conf updated to match: index.html stays no-cache (its URL never
changes, so a stale cached copy would keep pointing at deleted asset
filenames after a deploy), assets/ gets a long immutable cache (Vite
content-hashes every filename there, so a new build never reuses an old
URL). Dropped the old umd/ and img/ location blocks, neither exists in
the new build.

Verified locally: `docker build` succeeds, the container serves the new
app's real index.html/assets with the intended cache headers, and an
unknown client-side route (/messages/slf) correctly falls back to
index.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:56:21 +08:00
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00
Your NameandClaude Sonnet 5 72b7b79caf fix(web): code review round 3 -- UI bugs and remaining design fidelity
- FileAttachment never surfaced a failed authenticated download -- the
  card looked identically clickable whether it worked or not, and
  clicking while broken silently no-op'd. Added an error state/retry
  (useAuthedBlobUrl gained a retry() escape hatch for this).
  ImageAttachment/AudioAttachment already handled this; only the file
  card didn't.
- --shadow-btn was one token serving two different specs: round 2's fix
  (0 6px 16px, from the 50px auth CTAs) actually regressed the 36px
  messaging send button (ConversationView/VoiceRecorder), which needs
  0 4px 12px per 02-messages.dc.html. Split into --shadow-btn /
  --shadow-btn-sm.
- loadOlder's scroll-restore guard compared topicId by value, which
  false-positives on an A->B->A round trip within one request's
  lifetime (same string, but the DOM was torn down and rebuilt by the
  intervening open/close). Switched to a generation counter, same
  pattern as topics.ts's leave-race fix.
- errorToast wasn't included in ConversationView/DetailColumn's
  topicId-reset effects -- a failed action's toast could keep showing
  over a conversation/contact switched to afterward. Also switched
  those effects (and MessagesLayout's) from useEffect to
  useLayoutEffect -- since none of these components remount on
  topicId change, a post-paint effect could let one frame render with
  the previous topicId's stale UI still showing.
- VoiceRecorder never listened for MediaRecorder's own onerror -- a
  mid-recording failure left the "录音中" timer ticking forever with no
  indication the recording had already died.
- VerificationCodeButton's phone-change countdown reset (round 2) only
  cleared the button's own state; RegisterPage/ResetPasswordPage's
  challengeId was still tied to whatever phone the code was actually
  issued for, with nothing cross-checking it against a since-edited
  phone number.
- Agreement's checkbox used bare accent-color on a native input, which
  only tints the checked fill and leaves size/radius/border to the
  browser/OS; replaced with the spec's exact 16x16 border-radius:4px
  box + check glyph.
- Added a catch-all route (App.tsx), TenantFeedbackPage image-picker
  validation feedback + corrected remove-button style, and a handful of
  smaller token/spacing corrections against screens/*.dc.html (NavRail
  font-weight, LoginPage/TenantEntryPage logo size+shadow+color,
  ChatListColumn row padding/icon size, --shadow-modal alpha).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:27:41 +08:00
Your NameandClaude Sonnet 5 8ec74dffcc fix(web): code review round 3 -- surviving races and a shape bug
Round 2's dedup fixes for connect()/openConversation left two gaps that
only showed up under closer scrutiny (and, for the first one, actual
live reproduction):

- closeConversation() awaited the shared in-flight subscribe promise
  before calling leave(), but didn't check whether a NEWER
  openConversation() had since claimed the same topic while it was
  waiting -- under React 19 StrictMode's mount->cleanup->remount, the
  stale cleanup's leave() would detach the subscription the surviving
  mount had just established, silently killing live updates for
  whatever conversation you'd just opened. Fixed with a per-topic
  generation counter: closeConversation only actually leaves if no
  newer open has since claimed the topic.
- prefetchPreview (chat-list preview subscribe) never joined the same
  in-flight tracking as openConversation, so opening a conversation
  while its own preview subscribe was still resolving could still send
  two overlapping {sub} packets at the same topic.
- resetTinode() didn't clear connectPromise/restorePromise or
  disconnect a connection that was still mid-handshake (only guarded on
  isConnected()) -- switching tenants while the old connection was slow
  to respond could leave a new login silently waiting on the abandoned
  old connection's outcome.
- subscribeMeTopic() got the same in-flight dedup as the others, for
  consistency (no concrete concurrent caller today, cheap insurance
  against a future one).

Also:
- getBlocks()'s response type was a flat Friend shape; the real
  contract (confirmed against both the protocol doc's JSON example and
  the old app's own consumer) nests the user under `.user`, so "already
  blocked" state in DetailColumn was silently always false.
- useBootstrap() had no tenant in its query key and staleTime:Infinity,
  so switching tenants without a full reload kept serving the PREVIOUS
  tenant's message_policy/features/permissions/security forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:27:16 +08:00
Your NameandClaude Sonnet 5 c9f4615c40 fix(web): code review round 2 -- messaging UI bugs and design fidelity
- DetailColumn: remark-edit draft and the history-search modal's open
  state weren't reset on contact switch (no key={topicId}, same long-
  lived instance) -- could silently save a typed remark against the
  WRONG contact. History-search state lifted to MessagesLayout so
  ConversationView's header search icon (previously wired to nothing)
  can open the same modal
- ConversationView: loadOlder's in-flight "restore scroll position"
  callback and its loading flag weren't guarded against a conversation
  switch happening mid-request, and the scroll-to-bottom effect keyed
  only on messages.length so two conversations that happened to load
  the same page size never got scrolled down on open
- ChatListColumn: pinned 系统消息/文件助手 rows used solid-color-square
  avatars like regular contacts; the actual design-code source
  (ChatList.dc.html) and reference screenshots use a light tinted
  background with a colored icon instead
- MessageBubble: own ("我") messages hash-colored their avatar like any
  contact instead of the design's fixed accent blue
- Every send/recall/favorite/forward action swallowed its own failure
  silently (empty .catch or, for forward, no catch at all) -- moved
  Toast from features/auth into app/ (shared) and wired real error
  feedback through it
- MessageContextMenu: added the recall countdown label, Escape-to-close,
  and viewport-edge clamping (a right-click near the window edge could
  render partially off-screen)
- EmojiPanel now defaults to 最近 per the reference (was defaulting to
  表情)
- HistorySearchModal: pagination (searchTopicMessages/getTopicMedia both
  supported cursor/next_cursor already, nothing used it -- silently
  truncated at one page), Escape-to-close, and a brief "没有找到" flash
  on the first keystroke before the debounce caught up
- A handful of design-token corrections (--shadow-btn, NavRail's border/
  spacing, LoginPage's logo radius/brand-panel padding, a placeholder
  icon color) verified against screens/*.dc.html's exact pixel values

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:53 +08:00
Your NameandClaude Sonnet 5 d3a0eb0dbc fix(web): code review round 2 -- critical session/connection races
- RequireAuth: a page reload lost the session even with a valid token
  sitting in sessionStorage -- myUserId is never persisted (by design)
  but nothing ever re-derived it from the reusable login token. Added
  connection.ts's restoreSession(), which also fixes the race the first
  attempt at this ran into live: React 19 StrictMode double-invokes the
  restore effect in dev, and Tinode.loginToken() isn't idempotent (second
  call on an already-authenticated connection gets 409'd), so both the
  connect() and the login itself needed module-level in-flight dedup,
  not just a per-effect cancelled flag
- topics.ts: same dedup pattern for openConversation/closeConversation --
  StrictMode's mount->cleanup->remount always overlapped an open with
  itself, and a leave() landing before its own subscribe() resolved was
  silently no-op'ing instead of detaching
- LOGGING_ENABLED was unconditionally on, including prod builds --
  Tinode's verbose logging prints the raw login secret and full message
  content to the console
- imPost/imPatch/imPut/imDelete could silently drop the Idempotency-Key
  header for a caller that passes its own headers (object-spread order
  bug, dormant today since no call site does this yet)
- DetailColumn's getFriends()/getBlocks() only ever read page 1 despite
  fetchAllPages existing specifically to prevent this bug class
- availableActions() now defers to the server's message_policy.
  supported_actions when present instead of only a client-side time
  window, and exposes recallRemainingSeconds for the context menu's
  countdown label

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:28 +08:00
Your NameandClaude Sonnet 5 1bd8d7f804 fix(web): history search used response shapes the real server doesn't send
Confirmed live against the real backend: docs/IM-前后端对接文档.md's flat
`/media` items ({thumbnail_file_id,file_id,width,height}) and flat search
hits ({file:{...}}, {url,title,...}) don't match what the server actually
returns. Both endpoints really return the raw original message (`{txt,fmt,
ent}`, search hits wrapped one level deeper in `snapshot`) -- the same shape
lib/messages/drafty.tsx's renderDrafty already knows how to render, reused
here instead of hand-picking fields from a shape that doesn't exist. This is
also why image/video thumbnails were rendering as generic icons: the field
they were reading was never present in a real response.

Extracted useAuthedBlobUrl (authenticated file-fetch-to-blob-URL) out of
AttachmentPreview into lib/hooks.ts so the history modal's own thumbnail
grid can reuse it instead of duplicating the fetch/header logic.

Also adds the URL safety-check gate (docs/IM-前后端对接文档.md:1822,1898)
before opening a link found via the 链接 tab, which this view didn't have
at all before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:58:22 +08:00
Your NameandClaude Sonnet 5 93d4077d1e fix(web): code review round 1 remaining UI fixes
- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
  API, chevron_right icons now use --color-icon-arrow token (were tertiary
  text color), regroup rows so dividers only sit between sections not every
  row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
  spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
  bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
  existing SectionPlaceholder pattern since their own dedicated layouts are
  Phase 8, not built yet

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:58:06 +08:00
Your NameandClaude Sonnet 5 a7da4dc079 fix(web): code review round 1 -- messaging UI critical/high findings
- CRITICAL: draft text and voice-recording state were never isolated per
  conversation. ConversationView is one long-lived instance reused across
  every topic (`:topicId` route param change doesn't remount it), so a
  single `draft` string leaked one conversation's unsent text into whatever
  conversation was opened next -- and, far more seriously, starting a
  voice recording in conversation A and then switching to conversation B
  before hitting send would publish that recording into B: VoiceRecorder
  keeps its own MediaRecorder running across the switch, but the onSend
  callback passed to it is a fresh closure over whatever topic is CURRENT
  when it finally fires. Fixed by keying drafts per topicId and force-
  resetting `recording` (unmounting VoiceRecorder, which stops its stream)
  whenever topicId changes.
- CRITICAL: the detail/settings panel was completely unreachable below
  960px -- it only ever rendered inside `lg:block` (>=1280px, docked) or
  `md:block lg:hidden` (960-1279px, drawer) wrappers, so tapping the header
  "i" button below 960px flipped internal state and fired its data queries
  with zero visible effect. Added the README §1.4-mandated <960px full-page
  overlay tier, and a small new useMediaQuery hook so the header's "i"
  button highlight reflects whether the panel is ACTUALLY visible right
  now instead of being hardcoded to a breakpoint (it was previously always
  blue at 960-1279px regardless of the drawer's real open state, and always
  grey at >=1280px despite the panel being permanently docked open there).
- CRITICAL (visually confirmed against 1a-desktop-chat.png): messages you
  send never showed an avatar at all (just an empty spacer) -- every
  reference screenshot shows your own "我" avatar beside your bubbles the
  same as the other party's. MessageRow now takes myUserId and renders it.
  While in there, also moved the failed-send error icon beside the bubble
  (README: "气泡左侧") instead of below it with the retry text.
- CRITICAL: ChatListColumn's search box had no value/onChange at all --
  typing into it did nothing. Now filters by contact name; added the
  search_off/"未找到相关会话" and forum/"暂无会话" empty states README §2.1
  calls for, neither of which existed before.
- MEDIUM (real Tailwind cascade bug): the selected chat-list row's
  highlight was being overridden by the unconditional `hover:bg-[...]`
  class on the same element -- Tailwind emits hover-variant rules after
  base rules regardless of source order, so hovering an already-selected
  row visually un-selected it until the mouse moved away. Scoped the hover
  class to the non-selected branch only.
- NavRail: inactive icons used the wrong color token (icon-default #555c65
  instead of the spec's #656b74, this app's text-secondary); the unread
  badge prop was never actually supplied by AppShell, so the badge's
  otherwise-pixel-correct rendering never appeared in the running app.
  Fixed both, and gave BottomTabBar's 消息 tab the same real badge (computed
  once in AppShell from useChatList(), passed to both).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:23:54 +08:00
Your NameandClaude Sonnet 5 569f266390 fix(web): code review round 1 -- auth pages design fidelity + missing screens
- CRITICAL correctness bug: resetTinode() was defined but never called
  anywhere. Switching tenants (TenantEntryPage) only updated the session
  store; the Tinode singleton it drives is created once and permanently
  cached (getTinode() lazily creates it, reusing it forever afterward), so
  every subsequent login kept talking to the FIRST tenant's host regardless
  of which tenant was actually selected -- multi-tenant switching was
  silently broken. Also moved LoginPage's getTinode() call to after its
  "no tenant yet" redirect guard, so it can't get created against a
  not-yet-resolved tenant in the first place.
- Error strips across all four auth pages were missing the
  error_outline icon README §10.1 specifies.
- Fixed a token mix-up affecting every error-state input border across
  Register/PasswordInput: README's exact spec is border #d94b4b (this app's
  --color-danger-text) with #cf3f3f text below (--color-danger-border) --
  the code had them backwards, borders were using danger-border.
- TenantEntryPage: added the missing 企业注册/遇到问题 links (now real,
  working pages -- see below), fixed the business icon's color token
  (was accent #1677ff, spec calls for focus-border #4091ff).
- LoginPage/ResetPasswordPage: the "切换企业"/"返回登录" back link was nested
  inside the vertically-centered form container, so it got pulled to the
  page's middle instead of staying pinned to the top like every reference
  screenshot shows it. Also gave LoginPage's brand panel the spec's 56/44
  split (was an even 50/50), 40px headline (was Tailwind's 36px text-4xl),
  and the missing footer links row; added a mobile-only large-logo facade
  (matches 1e-mobile-login.png) since the desktop split-panel brand block
  is hidden below 900px and mobile had no tenant-identifying visual at all.
- RegisterPage: removed an extra 56px-logo+tenant-name block that appears
  in neither the desktop nor mobile reference screenshots; added the
  mobile-only 48px back-chevron+title header bar the design calls for
  (RegisterPage previously had zero back-navigation on any breakpoint).
- ResetPasswordPage: the password error text said "需要包含字母和数字" while
  the actual check only enforces a minimum length (>=8, no character-class
  requirement, matching Register's own rule) -- the message was promising
  a validation this page doesn't perform.
- VerificationCodeButton: the resend countdown's setInterval was never
  cleared on unmount -- a real leak if the user navigates away mid-countdown.
- New: EnterpriseRegisterPage (企业注册, README §10.5) and
  TenantFeedbackPage (问题反馈, README §10.6 / `01/1j`), both previously
  linked-to-but-nonexistent. lib/api/tenant.ts gained the REST calls they
  need (submitTenantApplication, submitTenantEntryFeedback,
  uploadPublicFile against the pre-login /v1/im/public/uploads surface,
  distinct from the chat-file upload endpoint).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:23:30 +08:00
Your NameandClaude Sonnet 5 986432addd fix(web): code review round 1 -- critical data-layer bugs
Three parallel adversarial code-review passes surfaced real defects across
the app. This commit covers the data/logic layer:

- CRITICAL: file upload/download used the wrong auth header entirely.
  Built from docs/IM-前后端对接文档.md as `X-MeetingIM-Auth: token <token>`,
  but the old app's own upload code carries an explicit comment that the
  real header is `Authorization: Token <token>` (capitalized scheme,
  standard header name), "verified live against the real server, not just
  read from source" -- and separately warns the SDK's own built-in
  LargeFileHelper gets this wrong too (sends X-Tinode-APIKey/X-Tinode-Auth,
  which this backend doesn't recognize). This means every external
  (non-inline) image/file/voice attachment -- anything too large to send as
  base64 -- was silently broken; only the earlier live tests happened to
  use small inline attachments, which never exercise this code path.
  Confirmed fixed live: a real >256KB image now uploads (200, real file_id)
  and downloads (blob URL, correct 800x600 dimensions) correctly.
- CRITICAL: TopicMe.onContactUpdate was never wired. Marking a conversation
  read (topic.noteRead()) updates the contact's unread count through ONLY
  this single-slot callback (confirmed against the SDK's own
  Topic.note()/TopicMe._refreshContact -- it does not also fire onData/
  onInfo/onPres, unlike every other _refreshContact call site, which do).
  Without it the chat list's unread badge/ordering went stale after reading
  a conversation until an unrelated topic event happened to invalidate the
  shared snapshot cache.
- HIGH: chat-list message preview didn't check isServerDeleted(msg) (the
  `hi` soft-delete flag), only `content == null` -- a hi-flagged message
  with non-null content leaked its real text/attachment label into the
  preview instead of showing "[消息已删除]".
- MEDIUM: upload failures showed raw untranslated SDK ctrl.text (e.g. "too
  large" instead of "文件大小超出限制") -- ported the old app's
  UPLOAD_CTRL_TEXT_CN table, which it built specifically because this
  native-protocol endpoint's errors never pass through the REST layer's
  own reason-code translation.
- Ambient SDK type corrections (tinode-sdk.d.ts): AccessMode.mode/given/want
  are numeric bitmasks at runtime, not the "JRWPS"-string type they were
  declared as (dormant today, nothing reads them yet, but wrong ahead of
  Phase 3's group-role logic); Drafty.sanitizeEntities's doc comment
  claimed a deep copy it doesn't do (verified it mutates entity .data in
  place); onAutoreconnectIteration's first param was misnamed "seconds"
  when the SDK always passes milliseconds.
- sendVoice() now passes `filename` to Drafty.insertAudio (was the only one
  of sendImage/sendFile/sendVoice omitting it).
- friends.ts's accept/reject-friend-request `version` param used a falsy
  check (`version ? ... : ...`) that would silently drop a legitimate `0`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:23:01 +08:00
Your NameandClaude Sonnet 5 d5440b962b feat(web): bulk forward/favorite in multi-select mode
Rounds out the multi-select bottom bar (README §3.6) with 转发/收藏 alongside
the existing 删除 -- reuses the single-message ForwardPickerModal/
favoriteMessages/forwardMessage machinery from the context-menu actions,
just applied to the whole selected set. One "转发" button (not separate
逐条/合并 buttons) matching the old app's real dialog-internal mode toggle,
which this app's simplified single-target picker doesn't model -- already
noted as a scope cut in ForwardPickerModal's own docstring.

Verified live: entering multi-select via a message's context menu shows
转发/收藏/删除, and bulk-favorite POSTs a real /v1/im/favorites/batch request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:51:29 +08:00
Your NameandClaude Sonnet 5 03b8098b3c feat(web): voice messages (record, send, playback)
Click-to-record (README §3.3's explicit 取消/发送 buttons), not the old
app's press-and-hold-with-swipe-to-cancel gesture -- the new design replaces
that touch gesture with plain buttons, a reasonable desktop-first
adaptation; the underlying MediaRecorder mechanics (audio/webm or
audio/mp4 depending on browser support, 24kbps, 2s minimum/10min maximum
duration) are ported as-is from the old app's audio-recorder.jsx.

- VoiceRecorder.tsx: recording UI + MediaRecorder lifecycle. Silently
  discards (no send) recordings under MIN_DURATION, matching the old app.
  Deliberately skips the `webm-duration-fix` package the old app pulled in
  for Chrome's well-known missing-webm-duration-metadata bug -- this app's
  own displayed duration comes from its own recording timer, not the file's
  embedded metadata, so only a browser's native <audio> scrubber would be
  affected, not anything this app actually shows.
- attachments.ts: sendVoice() -- inline base64 or upload-then-reference,
  same size-based branch as images/files, Drafty.insertAudio's confirmed
  {mime,val/ref,duration,size} shape.
- AttachmentPreview.tsx: AudioAttachment play/pause button + static
  waveform (CSS repeating-gradient, not real waveform analysis) + duration.

Verified the failure path live (this sandbox blocks getUserMedia): the
recorder's error state shows a clear message and recovers cleanly back to
the normal composer with no crash. The actual record -> encode -> send ->
playback round trip could not be exercised in this environment and needs
a manual check with real microphone access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:49:17 +08:00
Your NameandClaude Sonnet 5 aa3e464b35 feat(web): emoji picker
Plain system-emoji grid (README §3.3's mockup itself has no custom emoji
asset list, just gray placeholder tiles captioned "use system emoji or a
branded pack" -- matches the old app's own reasoning, no WeChat/QQ-style
sticker art available). Recent-emoji tab persists via localStorage, capped
at 30, same as the old app's send-message.jsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:45:55 +08:00
Your NameandClaude Sonnet 5 59d1033a92 feat(web): image and file message attachments (send + render)
- lib/api/upload.ts: POST /v0/file/u/ (docs/IM-前后端对接文档.md:460-461) --
  a separate surface from /v1/im/*, at the tenant's file base URL, auth'd
  with X-MeetingIM-Auth: token <token> rather than Authorization: Bearer
  (matches upstream Tinode's own LargeFileHelper convention).
- lib/tinode/attachments.ts: sendImage/sendFile. Files <= the inband limit
  go in directly as base64 (Drafty.insertImage's `bits` field / attachFile's
  `data` field) with no upload round trip; larger ones upload first and
  reference the returned URL. Simplified from the old app's urlPromise
  dance (optimistic local preview while the upload runs in the background)
  by just awaiting the upload before creating the message -- less
  optimistic, meaningfully simpler.
- AttachmentPreview.tsx: image/file bubble rendering. Externally-referenced
  (non-inline) attachments need an authenticated fetch into a blob URL,
  not a plain <img src>/<a href> -- GET /v0/file/s/{file_id} requires the
  same custom auth header uploads do, which a bare element can't attach.
- drafty.tsx: wired the 'IM'/'EX' Drafty style-tag cases to these.

Verified end to end: sent a real inline image (confirmed the exact IM
entity shape in the outgoing WS packet, and that the <img> decoded and
loaded from its own base64 data) and a real inline file (rendered with the
correct icon/size/download affordance).

Known gap: voice messages (MediaRecorder + AU entities) not built yet --
meaningfully more complex (hold-to-record gesture, audio encoding,
duration/waveform) and deliberately left for a following pass rather than
rushed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:44:12 +08:00
Your NameandClaude Sonnet 5 6fcd779530 feat(web): pagination, quote/reply, favorite, and single-message forward
- ConversationView: infinite-scroll "加载更多" -- scroll-to-40px triggers
  topic.msgHasMoreMessages()+getMessagesPage() to fill exactly the missing
  seq ranges (not naive since/before paging, since server-side deletions can
  punch holes in an otherwise-contiguous range), with scroll position
  preserved across the prepend.
- lib/tinode/send.ts: sendReply() ports the old app's exact quote
  construction (Drafty.replyContent truncates, Drafty.quote wraps with the
  sender's name, Drafty.append inlines the whole quote block ahead of the
  new text -- the receiving side never needs to look up the original
  message). forwardMessage() sends a single message directly to one picked
  target topic (ForwardPickerModal) -- simplified from the old app's
  stage-then-compose flow, trading that nicety for a much smaller dialog.
- lib/messages/drafty.tsx: added the 'QQ' Drafty style-tag case ported
  against the SDK's actual Drafty.quote() output shape (no `key`/`data` of
  its own -- verified by reading tinode.dev.js, not guessed).
- lib/api/messages.ts: favoriteMessages() -- a single message is just a
  one-item batch against POST /v1/im/favorites/batch (confirmed in
  docs/IM-前后端对接文档.md:1935).
- Context menu now shows 引用/复制/收藏/转发/多选/[撤回]/删除 in spec order
  (README §3.5); 翻译 still deliberately absent (no real backend for it,
  confirmed by research).

Verified end to end: sent a real quote reply and confirmed the exact wire
packet (head.reply, QQ fmt span, MN entity) and that it renders correctly
via the new QQ formatter case; favorite POSTed and returned created_count:1;
forward POSTed the expected {head:{forwarded:"topic:seq"}} packet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:39:23 +08:00
Your NameandClaude Sonnet 5 bccbdff9b2 feat(web): offline/reconnect/realname top banners (README §3.7)
Four states, one shown at a time (offline > reconnecting > just-reconnected
> realname-reminder priority): no-network (navigator.onLine + online/offline
listeners), reconnecting-with-countdown, just-reconnected, and a skippable
realname-verification reminder (bootstrap.security.realname).

- connection.ts: claims Tinode's onAutoreconnectIteration(timeoutMs, promise?)
  callback (single-slot, same pattern as onConnect/onDisconnect) and runs its
  own 1s-tick countdown off the first call's timeout -- the SDK gives the
  backoff length once per iteration, it doesn't push a running countdown.
  Verified the callback's exact three-shape contract (timeoutMs alone starts
  a countdown; 0+promise means a retry is now in flight; -1 means retrying
  stopped) by reading the SDK's own Connection class, not guessed.
  retryReconnectNow() backs "立即重试": clears the countdown and calls
  tinode.reconnect(true).
- "已重新连接,正在同步 N 条新消息" intentionally shown without a fake N --
  confirmed via research that the old app never implemented this state at
  all, and there's no reliable signal here for how many messages arrived
  while disconnected; inventing a count would be dishonest data.

Verified live: toggling browser online/offline events correctly shows and
clears the no-network banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:31:32 +08:00
Your NameandClaude Sonnet 5 80bf0aa41f feat(web): 查找聊天记录 history search + media grid in the detail panel
Closes a real visual gap flagged directly: the detail panel jumped straight
from the quick-action buttons to 备注/置顶聊天/etc, skipping the "查找聊天
记录" row + 4-icon media grid (图片/视频/文件/链接) that's prominently
visible in the reference design (README §3.4) -- deliberately deferred in
an earlier pass, but a dead-end-free real implementation was worth building
now rather than leaving a visible hole.

- lib/api/messages.ts: searchTopicMessages/getTopicMedia, endpoint shapes
  from docs/IM-前后端对接文档.md:1251-1254 (verified live: both return real
  200s against the actual backend, not just matching the docs on paper).
- HistorySearchModal: centered modal (README §1.5's 弹层 pattern) with the
  five tabs, debounced search (lib/hooks.ts's useDebouncedValue, ported from
  the old app's use-cached-data.js), empty/loading states.
- DetailColumn: wires the row + grid to open the modal on the right tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:27:12 +08:00
Your NameandClaude Sonnet 5 0b48346d02 fix(web): avatar background color was invisible everywhere (transparent)
avatarBgClass() built its class name via template-literal interpolation
(`bg-avatar-${i + 1}`). Tailwind's build finds utility classes by statically
scanning source text for complete class-name strings -- a dynamically
assembled name is invisible to that scan, so `.bg-avatar-1` through
`.bg-avatar-11` were never generated in the compiled CSS at all. Every
avatar in the app (chat list, conversation header, message bubbles, detail
panel) rendered with a fully transparent background instead of its color --
the element's className looked correct in the DOM, which is why this
shipped across several commits before being caught by checking computed
style rather than the class name itself.

Fixed by writing the 11 class names out as a literal array Tailwind's
scanner can actually see.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:23:20 +08:00
Your NameandClaude Sonnet 5 2272b42c6f feat(web): message context menu, recall, and basic multi-select
Right-click a message for 复制/撤回/删除/多选 (README §3.5). 引用/收藏/翻译/
转发 deliberately left out of this pass -- quote and forward need their own
composer-state flows, and there's no confirmed single-message favorite
endpoint yet; a menu item that doesn't work is worse than a smaller correct
menu.

- lib/messages/actions.ts: gates recall to own messages, server-confirmed
  seq (not the SDK's local pending-seq placeholder), and bootstrap's
  message_policy.recall_window_seconds -- scoped to P2P for now, no
  group-admin recall yet.
- lib/api/bootstrap.ts + api/hooks.ts: first GET /bootstrap wiring
  (message_policy, forward limits, etc.), cached indefinitely per session.
- lib/api/messages.ts: recall goes through REST, not native WS -- and
  requires a body `{version, reason}` this backend tracks per-message
  independently of Tinode's own seq (confirmed in
  docs/IM-前后端对接文档.md:1248 and by hitting a live 400
  MALFORMED_REQUEST/version first, since no version field is actually
  delivered anywhere in the WS {data} packet this app reads messages from).
  Defaults to version:1 -- the correct value for any never-before-edited
  message, which covers the common case; a real edit/recall race surfaces
  as a version-conflict error rather than being silently papered over.
- Basic multi-select: enter via the context menu's "多选" (no long-press
  gesture yet), header becomes "已选择 N 条消息"+取消, bottom bar currently
  offers only 删除 (bulk 转发/收藏 wait on those features existing).

Verified end to end: recall correctly withheld past the 120s window and
correctly available within it, the real recall REST call round-trips and
the message disappears locally; multi-select mode enters/exits and
pre-selects the long-pressed/right-clicked message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:01:53 +08:00
Your NameandClaude Sonnet 5 7f5756671d feat(web): real P2P chat-settings detail panel
Replaces the detail-column placeholder with the actual "聊天设置" panel
(README §3.4): profile header, quick-action buttons, remark, pin/mute
toggles, blacklist, clear-history, delete-friend -- backed by real REST
endpoints (lib/api/friends.ts, ported from the old app's real
contact-management-view.jsx settings panel, not its unrelated mock
direct-message-view.jsx).

First real use of TanStack Query in this app (useQuery for
friends/conversation-settings/blocks, useMutation for the writes) --
previously only wired up in App.tsx with nothing consuming it yet.

Verified end to end: pin toggle PATCHes /conversations/{topic}/settings
and persists; remark PATCHes /friends/{userId} and reflects immediately
via query invalidation; blacklist PUT/DELETEs /blocks/{userId}.

Known minor gap: the drawer variant (960-1279px tier) stays mounted
(CSS-hidden) alongside the docked panel at >=1280px rather than
unmounting, so both instances' queries run -- harmless since TanStack
Query dedupes/shares the cache by key, but worth a follow-up cleanup.
Also still missing: the <960px "detail settings push as a full page"
variant the mobile spec calls for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:54:54 +08:00
Your NameandClaude Sonnet 5 f549248c08 feat(web): real chat list + P2P conversation view against live Tinode data
Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.

- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
  (Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
  out). Implements the old app's subscribe lifecycle: `me` subscribed once
  per session (metadata only), each contact gets a one-shot preview
  subscription (withLaterData(1), never left), opening a conversation
  leaves-then-resubscribes with a full history page (withData, not
  withLaterData -- a preview prefetch already nudging _maxSeq would make
  withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
  useSyncExternalStore, snapshots cached and only invalidated on real
  topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
  (optimistic local echo), not the simpler publish() -- matches the old
  app's approach. retryMessage flushes the failed local entry and rebuilds
  before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
  formatting utilities. Avatar color hash ports the old app's Java-style
  string hash but with modulus 11 (this design's palette) not the old app's
  16. @mention renders flat accent blue per the new design, not the old
  app's per-user hash color -- a deliberate simplification. File-type icons
  (PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
  documents identically. Date/time formatting is written fresh against the
  design spec (today+time combined, weekday tier) since the old app never
  combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
  the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
  surface needed above, verified against the installed SDK source
  (createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
  Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).

Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:49:14 +08:00
Your NameandClaude Sonnet 5 aa1c46ff22 feat(web): full auth screens rework to match design + old-app business rules
Rebuilt tenant/login/register/reset-password to match the design handoff's
01-account screens precisely (previously only loosely approximated), and
ported the old app's real business rules instead of guessing:

- LoginPage: desktop split branding panel (>=900px), terms-agreement
  checkbox gating submit, forgot-password link, password visibility toggle,
  full ctrl-error translation table (lib/tinode/errors.ts, ported from the
  old app's CTRL_ERROR_TEXT_CN/translateCtrlError -- registration/login go
  over native WS, not REST, so client.ts's REASON_MESSAGES never applied).
- RegisterPage: rebuilt as the real unified username-OR-phone form (not
  phone-only) -- username optional unless phone is blank, invite code
  required+validated only on the username-only path, matching the server's
  actual resolveRegistrationAccount rules. connection.ts's
  registerAccountByPhone generalized into registerAccount() supporting both
  paths, including the Tinode.credential(null) crash the old app had to
  work around on the username-only path.
- New ResetPasswordPage wired to /auth/password-reset/* (a different
  endpoint family than registration's /auth/verification-codes/*).
- Shared components: PasswordInput, VerificationCodeButton, Agreement,
  Toast.
- Fixed tinode-sdk.d.ts: the real method is getCurrentUserID(), not the
  invented getAuthenticatedUserID() -- caught live, login crashed past a
  successful auth.
- Fixed a responsive bug present on all four auth pages: fixed w-[380px]
  forms overflowed past the viewport edge below ~430px wide.

Verified end-to-end against the real backend (api.118im.com, tenant
"default"): fresh phone registration, password reset, re-login with the
new password, and the username-only path's invite-code rejection all
round-tripped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:11:21 +08:00
Your NameandClaude Sonnet 5 63c1b84b41 feat(web): phone registration + fix login/register response handling
- Add RegisterPage (phone + SMS code) and lib/api/auth.ts verification-code
  endpoints, wired into App.tsx routing.
- Fix registerAccountByPhone(): the injected acc.phone extra field must be
  bare 11-digit, not E.164 -- server checks digit length before normalizing.
- Fix LoginCtrl shape: SDK's login/loginBasic/createAccountBasic promises
  resolve with the ctrl fields flat, not wrapped in {ctrl: {...}}.
- Fix tinode-sdk.d.ts: the real method is getCurrentUserID(), there is no
  getAuthenticatedUserID().

All three bugs found and fixed via live testing against the real backend
(api.118im.com, tenant "default"), confirming registration and login both
work end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:51:06 +08:00
Your NameandClaude Sonnet 5 8cdd84152d 企业码解析+登录跑通真实后端:Tinode连接适配层、类型化API客户端、会话store
lib/tinode/connection.ts:单例Tinode连接+自己的发布订阅层——SDK的onConnect/onDisconnect/onLogin
是单槽位属性(赋值会互相覆盖),不是真正的多监听器事件源,所以这里统一接管这几个槽位,自己
扇出给任意数量的React订阅方(lib/tinode/hooks.ts的useSyncExternalStore)。installTenantHiPatch
照搬旧应用tinode-web.jsx的实现搬过来——服务端{hi}包的tenant字段SDK本身没有API,只能在原始
sendText层面注入,这是后端协议的真实约束,不是旧代码库特有的写法。

lib/api/client.ts:REST客户端按旧应用src/lib/im-api.js的request函数+完整REASON_MESSAGES错误码
翻译表搬过来(含请求头X-MeetingIM-APIKey等几个月联调换来的正确写法),这次是重新用TypeScript
写、不是复制文件,但端点行为对齐。

实机联调过程中发现并修正了一处凭记忆猜错的地方:tenants/resolve真实响应结构是
{endpoints:{api,file,websocket}, tenant:{code,name}},不是我最初假设的顶层{code,name}——
已用真实请求验证修正。用企业码default解析成功(显示"Default Tenant")+ 一次真实错误密码
登录尝试(服务端真实401,错误提示正确显示"账号或密码错误")两步都对着真实后端验证通过,
不是只测了正确路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:38:50 +08:00
Your NameandClaude Sonnet 5 8bcd9f3a5b 新分支:Vite+React19+TS+Tailwind v4 重新搭建,先跑通四级响应式布局壳
不再沿用旧 im_webapp 的技术栈(webpack + class组件 + 15000行全局CSS + 手搓History路由),
按业务重新选型:Vite/React Router/Zustand预留/TanStack Query/保留tinode-sdk真实依赖
(协议客户端本身没有重写的业务理由)。新代码放在 web/ 子目录,旧代码原样保留在仓库根目录,
两者共存到重写完成。

这一步先验证布局壳本身:NavRail(消息/通讯录/收藏/文件助手/通话记录/设置)+ 会话列表 +
主区域 + 详情栏,按设计稿的四档断点(<640/640-959/960-1279/≥1280)用真实浏览器宽度逐档验证
过——两列及以下"列表/主区域二选一+返回键"、三列"详情栏抽屉化"、四列"详情栏常驻"均已用
真实点击/返回验证正确,不是只看了截图。会话列表和聊天内容目前是占位数据,业务数据接入和
Tinode连接是下一步。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:24:11 +08:00
Your NameandClaude Sonnet 5 4e8f45d7d8 路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径:
parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调
pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash
那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack()
不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方
原本直接读的 window.location.hash(这个类从来没包装过这个读取)。

一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里
的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫
会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理
这种情况,对外的调用方(19个文件)完全无感。

tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate;
service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行,
没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用
都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误
资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只
更新了注释。

实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回"
按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/
个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:00:39 +08:00
Your NameandClaude Sonnet 5 97a6b88d59 修复"查找聊天记录"列表条目上下错位、长内容不换行溢出的问题
.library-message-results > button 沿用全局 button{height:2.2rem} 的重置,与自己的
min-height:5.5rem 叠加后变成一个"clamp出来的定长"而非auto——内容一旦比这个定长高(比如摘要
换行),这一条本身不会长高,而是让自己末尾的 <small> 时间戳溢出到下一条的发送人名字位置,
视觉上像是变成了下一条的开头,看着上下错位。同一个全局重置还带了 white-space:nowrap,摘要
<p> 继承后完全不换行,比栏宽的内容(尤其是链接)直接顶穿右边界。

改用 height:auto 让每一条按内容真实需要的高度伸展;<p> 补 white-space:normal +
overflow-wrap:anywhere(跟 .bubble div.message-content 处理长链接的方式一致)。移动端窄屏下
实机复现后验证:摘要正确换行、每条自己的时间戳不再越界到下一条。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:09:06 +08:00
Your NameandClaude Sonnet 5 7981083cad 修复发出消息里的超链接因为和气泡背景同色而完全不可见的问题
上一版把发出气泡背景改成蓝色(--clr-primary-accent)时漏了一处:全局 a{color} 用的正是同一个
token,链接文字和气泡背景撞成一模一样的颜色,肉眼完全看不见(但DOM里其实是好的,实测<a href>
标签和完整URL文本都在)。用真实账号发一条带链接的消息复现后确认。改为链接色跟随气泡文字的白色+
下划线,颜色不能再区分链接和普通文字时用下划线区分。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:58:49 +08:00
Your NameandClaude Sonnet 5 48baeceaa8 修复长按消息气泡弹出自定义菜单时,浏览器/系统原生右键菜单跟着一起弹出的问题
chat-message.jsx 新增 suppressNativeContextMenu,在气泡上监听 contextmenu 事件并 preventDefault,
避免长按(或桌面端右键/触屏模拟长按)触发的原生上下文菜单和应用自己的长按菜单重叠显示。
css/base.css 给 .bubble 补了 -webkit-touch-callout:none,同时覆盖 iOS Safari 那条独立于
contextmenu 事件之外的长按呼出菜单(复制/查询/分享...),不影响 user-select,桌面端正常的
点击拖拽选字复制不受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:50:06 +08:00
Your NameandClaude Sonnet 5 4ce666042e 修复系统消息/我的收藏页"加载更多"按钮文字下沉被裁切的问题
.system-notification-load-more/.library-load-more 只覆盖了全局button的字号和内边距,没覆盖
line-height,实测继承的全局button{line-height:2.2rem}比这两个按钮自己的行高需求高出一大截,
把文字挤到框内偏下的位置,底部还被裁切掉约2px(用getBoundingClientRect量出文字整体比按钮
边框低9.9px)。改用flex居中+height:auto,不再依赖固定line-height撑出的位置。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:41:59 +08:00
Your NameandClaude Sonnet 5 f0091fb146 修复消息气泡配色未跟设计稿核对过的问题:发出气泡改蓝底白字,接收气泡改浅灰,日期分隔去掉气泡背景
对照 docs/images/个人对话框(我的状态/对方的状态).png:发出=蓝色实心+白字,接收=浅灰+深字,
与其余相关截图口径一致;旧配色(接收浅绿#c4e0a3、发出用页面同款off-white)不匹配任何参考图,
是尚未跟设计稿核对过的原始默认值。顺带修了一个自查时发现的新问题:已读回执图标复用的.blue
工具类和新的蓝色发出气泡背景是同一个色值,之前会变成看不见的勾选图标,改为不透明白色。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:41:18 +08:00
Your NameandClaude Sonnet 5 a8b81a51e3 修复本轮UI走查发现的6处样式/交互问题
1. 企业注册-统一社会信用代码:扫码图标误用 icon+onIconClick(渲染在输入框左侧、蓝色),
   跟设计稿(图标在右侧、深灰色)不符。原因是 Field 组件里 icon 出现在 <input> 之前;
   全仓库唯一一处 onIconClick 用法,改用 PasswordField 同款的 action(渲染在右侧),
   复用已有的 .auth-inline-icon 灰色样式,不新增CSS。

2. 意见反馈:图片上传按钮跟设计稿差异较大(当前是通栏虚线框+蓝色图标文字,设计稿是
   独立白色方块+灰色图标文字);字数"0/300"计数器卡在textarea自带边框和外层白卡的接缝
   处,看起来像叠在两层盒子中间。改法:拆分原本跟"举报证据上传"共用的CSS规则,给
   .feedback-upload>button 单独设置白底/无边框/灰色/7.5rem方块;去掉textarea自身的
   border,让计数器所在的外层卡片成为唯一可见的白色容器。"图片"标题跟数量合并回一行
   (原来用space-between分居两端),maxFiles真实上限(5,早前已确认是后端真实值)不变。

3. 青少年模式(开启/关闭/验证三个页面互跳):三处跳转全部误用push(HashNavigation.
   navigateTo),导致点返回会先回到流程内已经处理完的页面,跟本工程之前排查过的
   同类"tab切换污染历史记录"是同一个模式。新增 navigateReplace()(跟 navigate() 同结构,
   内部换成 HashNavigation.navigateReplace,抄的是 message-center-view.jsx/
   content-library-view.jsx已验证过的写法),三处调用改用它。

4. 修改密码页面:复用了 profile-editor-page 的通用label/input样式,但没有像
   BirthDateEditor那样带上专属wrapper class,导致三个label的<span>不加粗不换行、
   紧贴在一起零间距,input还留着给别的编辑器计数器/清空按钮预留的3rem右侧空白。
   新增 change-password-fields 样式(参照birth-date-editor的label/span/input规则)。

5. 消息长按弹出菜单:全历史检索确认这个功能从来不是真正的长按手势,唯一实现是点击
   气泡右上角一个不透明度只有0.3的小箭头按钮——参考图"消息页面—长按弹出.png"命名的
   功能需求和实现从未对齐。给 .bubble 补上真实的touchstart/touchmove/touchend长按
   识别(500ms计时器,移动超过10px视为滚动取消),复用同一套菜单构建逻辑(从
   handleContextClick里抽出openContextMenu(x,y)公用)。验证时发现并修复了一个重构
   引入的真bug:openContextMenu里遗留了两行没改完的e.pageX/e.pageY(应为参数x/y),
   会导致点击小按钮和长按两条路径双双抛ReferenceError,已修正并重新验证两条路径都正常。

6. 群公告横幅(群聊导航栏下方常驻提示条):当前是通栏无圆角、贴边无留白、底部硬分隔线、
   文字用的是全局默认灰色(#666)文本token,设计稿是浮在灰底上的白色圆角卡片、四周留白、
   无分隔线、文字接近黑色。改法:加margin+border-radius+box-shadow做出浮起的卡片感,
   去掉border-bottom,颜色改用--clr-bubble-text(消息正文同款深色token,而不是改全局
   的--clr-text-primary,避免影响其它用到这个默认文本色的地方)。

验证:本地dev-proxy逐项验证——企业注册图标位置颜色(截图核对)、意见反馈布局(截图核对,
0/5计数器无缝落在白卡右下角)、青少年模式三处调用grep确认无遗漏+navigateReplace底层
history.replaceState机制单独验证不增加history.length、修改密码页面(截图核对label加粗
间距生效)、长按弹出(真实dispatch TouchEvent触发菜单成功,且验证点击小按钮路径未被
重构破坏)、群公告横幅(getComputedStyle核对margin/borderRadius/boxShadow/color/
borderBottom五个属性全部生效)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:24:24 +08:00
Your NameandClaude Sonnet 5 6ab2867f27 重新构建dev产物,反映本轮死代码清理+收藏批量删除弹层+通讯录CSS修复三处改动
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 01:59:33 +08:00
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
Your NameandClaude Sonnet 5 46a98782fd 修复"退出当前登录"仍用原生 window.confirm(),与其余三个确认弹层不一致
现象:清除缓存/清除聊天记录/注销账号三处早前已经统一改成组件内 ConfirmSheet 弹层
(本文件其它三处组件自己的注释也在讲这次统一),唯独"退出当前登录"还留着最原始的
window.confirm()。本轮测试发现这在当前浏览器自动化环境下会被判定为"环境不支持同步
弹窗"而直接返回 false(控制台可见"Page dialog suppressed"警告),退出登录点了完全
没反应,且没有任何错误提示——这不只是测试环境的偶然状况:不少内嵌 WebView/嵌入式
容器场景本身就会禁用 confirm()/alert() 这类阻塞式原生对话框,真实用户在这些环境下
点"退出登录"会遇到一模一样的静默无效。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:20:04 +08:00
Your NameandClaude Sonnet 5 290fc80f3e 群二维码行的小图标也应跟箭头贴在一起靠右,而不是单独留在文字标签后面
上一次修复把 margin-left:auto 放在了这一行的最后一个图标(箭头)身上,箭头确实到了最右,
但二维码小图标是这一行倒数第二个子元素,没吃到这个 auto margin,还留在原位——变成"二维码
图标贴左、箭头单独贴右"两边分家。auto margin 改放到这一组里最靠前的图标(二维码图标)身上,
箭头作为它后面的下一个兄弟元素自然跟上,两个图标现在整组一起贴右。

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:48:28 +08:00
Your NameandClaude Sonnet 5 d42591284f 第五轮深度测试:管理员撤回、所有人@提醒、失效附件UI,并核对现存后端问题清单
- messages-view.jsx: 拖拽发送/回复菜单在编辑态下的旁路修复;群主/管理员可撤回他人消息
  (对齐后端 authorizeIMMessageMutation 的 allowAdmin);新增"所有人"@提醒(head.mention_all)。
- chat-message.jsx: system_action 头类型消息按 system_notice 同款渲染(此前显示"内容无效")。
- content-library-view.jsx: 语音/链接/图文收藏与聊天记录卡片补上 removed(已失效)态UI,
  之前只有 FileCard 处理了这个状态。
- call-panel.jsx: handleVideoOfferMsg(5步.then链,用reject sentinel跳过后续步骤)、
  handleToggleSpeakerClick 补组件卸载守卫,修完此前遗漏的两处。
- group-management-view.jsx: 治理开关(Governance.setField)和群主转让(OwnerSelect)
  补上乐观锁 version 参数(此前从不传,服务端 version<=0 时会跳过冲突检测);群文件搜索框
  文案承诺"搜索文件名或者上传者"但服务端q参数只匹配文件名,改为拉取全量列表本地按文件名+
  已解析的上传者昵称一起过滤;群公告详情页补上从未被调用过的已读上报(readGroupAnnouncement)。

以上均用真实账号在浏览器/直接请求验证:管理员撤回、system_action渲染、mention_all、
文件按上传者搜索、公告已读上报(200)、治理开关stale-version探测确认409冲突检测生效。

7个并行测试agent对全部11章做了第二轮回归+现存后端问题清单逐条复核,结论待整理进
docs/IM-后端问题清单*.md(全部现存条目复核后均建议保留,多条证据强度从推断升级为已验证)。

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:29:52 +08:00
Your NameandClaude Opus 5 505a6d7d56 补齐第3-4章composer功能缺口:跨话题草稿/@提醒/表情面板/附件面板/名片/收藏发送/按住说话/语音未读红点
第3-4章复测确认这批原型图要求的功能之前完全没做(真实聊天引擎 messages-view.jsx/
send-message.jsx 从Ch1开始一直是最基础的版本:两个附件图标+文本框+发送/麦克风,
没有@、没有表情面板、没有7宫格附件面板、没有名片/收藏发送、语音是点一下进入录音模式
不是按住说话)。这次一次性把8项都接上,除了新功能外,过程中顺带发现并修了4个真bug。

**跨话题草稿**(im-api.js 新增 getTopicDraft/updateTopicDraft,messages-view.jsx)
话题切换时拉取 GET /topics/{topic}/draft(真实验证过P2P用原生话题名——对方user_id——
可以正常读写,接口内部自己会查Subs.Get校验,不需要额外归一化),输入框内容600ms防抖
PUT回去;真正发送成功后立即清空服务端草稿,不等下一次防抖。真实验证:输入文字→切走
再切回→草稿还在;刷新整个页面(新WS会话)→草稿依然从服务端正确恢复。

**@提醒**(send-message.jsx 新增 mention 相关状态和UI,messages-view.jsx 新增
mentionSearch/mentionedUsers/applyMentionResolution)
只在群聊开放,检测"光标前最近一个未闭合的@"触发面板,调 getGroupMembers(topic,
{mentionable:true, query}) 做实时搜索。协议层面:Tinode原生Drafty.parse本来就会把
纯文本里的"@word"自动识别成MN实体,但data.val存的是匹配到的原始文字,不是uid——
真正的uid只有用户从选择器里点选时才知道,记在 messages-view.jsx 的
this.mentionedUsers,发送前重新parse一遍把匹配上的占位符换成真uid。真实验证:选
"kcg051307"发送后直接查后端存储,MN实体的data.val确认是真实uid
"usrYVeV0yZs8tw",不是原始文字。

**表情面板**(send-message.jsx)
2个tab(😊表情/♡常用),常用表情本地localStorage维护。图片没有拍到第二个tab具体
内容,判断"常用"是这类两tab设计里最常见、也是本项目里唯一有本地存储依据能撑起来的
语义。真实验证:点表情正确插入文本框,用过的表情正确出现在"常用"tab。

**7宫格附件面板**(send-message.jsx,替换原来常驻的照片/文件两个图标)
照片/拍摄/语音通话/名片/文件/收藏/视频通话。语音通话、视频通话复用第8章已有的
handleStartAudioCall/handleStartVideoCall(从 tinode-web.jsx 新抽出 canStartCall()
方法,同时替换掉"..."菜单里原来的内联判断,避免同一个判断逻辑两处维护),仅P2P且
真实通话功能可用时才不置灰——真实验证过群聊里这两项正确显示为禁用状态。

**名片消息**(send-message.jsx openCardPicker/sendCard,复用原生TheCard/
Drafty.appendTheCard)
选好友生成名片,uid必须用TheCard.setTinodeID写进vCard的comm数组(不是随便塞进某个
字段——widgets/the-card-mini.jsx渲染时用TheCard.getFirstTinodeID读这个协议项来判断
"发消息"按钮指向谁)。真实验证:选中好友后发出的名片消息真实带"聊天"按钮,点击可用。

**收藏发送到当前会话**(send-message.jsx openFavPicker/sendFavorite,复用Ch7的
forwardFavorites接口)
真实联调中发现并修复一个新bug:这个接口和其它几个批量接口一样,HTTP层恒定200,
真实成败要看items[].status——原来的写法完全没检查这个字段,等于假装总是成功。已改成
检查失败项并报错。同时真实测试还发现该接口对P2P目标会返回200+items[0].status:
"failed", reason:"PERMISSION_DENIED"(哪怕是真实好友、这个会话本身发消息完全正常),
看起来是后端这个接口自己的写权限判断有问题,不是前端能修的,已记录。

**按住说话+上滑取消录音**(send-message.jsx新增手势处理,audio-recorder.jsx新增
holdMode展示态)
mousedown/touchstart直接挂载AudioRecorder(挂载即开始录音,复用原有机制不动),
移动超过80px判定为"松开取消",松手时通过ref直接调组件内部的handleDone/handleDelete。
过程中发现并修复3个真bug:① injectIntl包裹AudioRecorder默认不支持ref转发
("Function components cannot be given refs"警告,ref静默失效),加
{forwardRef:true}修好;② handleDelete从来没有像handleDone那样判空mediaRecorder,
录音还没真正开始(getUserMedia尚未resolve)就点删除会直接抛TypeError,这是原有代码
一直存在的问题,这次因为按住松手可能在录音刚开始的瞬间发生而被真实触发;③
cleanUp()同理没判空this.stream。补上判空后,"从未真正开始录音就结束"这个场景会
直接当作取消处理并正确调用onDeleted,不会再把UI卡在录音态出不来(真实验证:这个
环境的浏览器面板本身会拦截麦克风访问,用这个"getUserMedia永远拒绝"的真实场景反而
完整测出了正常/取消两条路径在录音真正开始前结束时都不再卡死或报错)。

**语音消息未读红点**(chat-message.jsx/audio-player.jsx/formatters.js)
纯客户端概念,原生Tinode的recv/read只到"消息本身有没有被看到",跟"这条语音有没有
真的被点开听过"是两回事。audio-player.jsx的播放按钮新增可选onPlay回调(不传不影响
现有调用方),formatters.js的AU分支透传给它,chat-message.jsx按话题维护一份
localStorage"已播放seq集合",对收到的(非自己发的)语音消息,没播放过就在气泡右上角
显示红点。真实验证:用原生SDK的_routeData往话题本地缓存注入一条真实结构的语音消息
(浏览器面板拦截麦克风,没有真实语音消息可用,用这个方式验证渲染/播放回调/持久化
三段逻辑),红点正确出现;直接触发播放回调后红点消失且localStorage正确写入对应seq。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:08:38 +08:00
Your NameandClaude Sonnet 5 2c77a8e229 Ignore local .claude/ dev-server config
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:01:13 +08:00
Your NameandClaude Sonnet 5 251c9de87c Convert im_webapp from static mock prototypes to real backend integration
Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.

Backup checkpoint before reconciling with origin/dev's parallel rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:40:53 +08:00
richjohnrambo ba8f48ee83 完成 IM Web Android 端产品页面开发 2026-08-25 18:30:40 +08:00
Your Name ca5e74ee98 修复静态资源发布后不生效: /umd/缓存策略从immutable改为no-cache
构建产物文件名不带内容哈希,一年免验证的缓存策略导致每次蓝绿发布后
浏览器和CDN都会继续吃旧版本,用户需要清缓存或开无痕窗口才能看到更新。
改为no-cache后浏览器/CDN每次都会带条件请求向源站确认,未变化时只是
一次很快的304,一旦文件真的变了会立刻拿到新内容,不再需要手动清缓存。
2026-08-12 12:54:41 +08:00
Your Name 111d9040a7 修复健康检查失败: nginx补上IPv6监听
首次部署时健康检查一直失败——容器内nginx只listen了IPv4(0.0.0.0:80),
但/etc/hosts里localhost解析到::1在前,wget http://localhost/因而连不上。
补上 listen [::]:80 双栈监听解决。部署脚本那边也同步改成直连127.0.0.1,
避免再依赖localhost的解析顺序。
2026-08-11 12:52:06 +08:00
Your Name 85ac8ce43b 接入Gitea Actions蓝绿发布:API_KEY构建时注入、新增Dockerfile
- src/config.js 的 API_KEY 改为从构建时环境变量 TINODE_API_KEY 注入,
  不再依赖官方demo默认值;未设置时保留原demo key作为本地快速测试的兜底
- webpack.config.js 新增 DefinePlugin 完成注入,并自动加载(gitignored的)
  .env.local,开发者本地调试不需要每次手动export
- 新增 Dockerfile(node构建+nginx serve) 和 docker/nginx.conf
- 新增 .gitea/workflows/deploy.yml:手动触发的蓝绿发布流程
2026-08-11 12:40:12 +08:00
or-else 14e1e6b549 bug fix: typo 2026-07-04 16:01:47 +03:00
or-else 70a9ab8996 firebase updated, two bugs related to fb fixed 2026-07-04 15:42:55 +03:00
or-else d24b297685 adding new files 2026-07-04 15:19:29 +03:00
or-else 7bf3fa71d6 partially dependencies updated 2026-07-04 15:19:03 +03:00
Gene ac9d61eaad Merge pull request #143 from Moraussubara-Shi/devel
proposed Portuguese translation
2026-05-23 22:07:47 +03:00
Moraussubara Shi ece2aa0e87 proposed Portuguese translation 2026-05-20 20:12:23 +01:00
or-else ac779ef31b merge devel 2026-03-03 13:36:48 +03:00
or-else e2c32de5c2 update the sdk version 2026-03-03 13:32:29 +03:00
or-else 6bd9afe017 update translations 2026-03-02 14:39:27 +03:00
or-else 91799adb9a rebuilt, new translation modules added to git 2026-03-02 14:11:36 +03:00
or-else 3bfabc90f3 add missing vi translations, rearrange language imports in alpha order 2026-03-02 14:06:40 +03:00
Gene 6b21c1deaa Merge pull request #141 from Antoanyabo/i18n-vi
feat(translation): Add Vietnamese (vi) translation
2026-03-02 13:34:19 +03:00
amit d674deeca6 feat(translation): Add Vietnamese (vi) translation 2026-03-02 15:07:52 +07:00
or-else 573f99993a rebuilt with the sdk bug fix 2026-03-01 19:05:14 +03:00
or-else e6dfde28f6 restore topic subscription after a reconnect 2026-03-01 18:31:24 +03:00
or-else 389ef305ab cleanup 2026-03-01 14:03:18 +03:00
or-else 2aa1b731ee fix for #140, fix for some UI breakage, cosmetic changes 2026-03-01 13:59:59 +03:00
or-else 656aebdb5d rebuild with the correct version of the sdk 2026-02-13 15:43:24 +03:00
or-else e9d6c2e5bf fix #139: use dvh/dvw css units for the top viewport size 2026-02-13 15:38:45 +03:00
or-else e097f134b7 dependencies updated 2026-01-20 13:02:19 +03:00
or-else d296bebe1c bump version to 0.25.2 2026-01-20 13:00:39 +03:00
or-else 234fb48b93 cleanup 2026-01-20 12:58:58 +03:00
or-else a54ae70746 split out a few modules, handle esc key 2026-01-19 15:41:33 +03:00
or-else 5b7565c8f8 debug logging removed 2026-01-17 12:58:36 +03:00
or-else dea781b423 adding TheCard preview 2026-01-17 12:55:45 +03:00
or-else 930f4f0656 TheCard handling in messages 2026-01-16 20:51:57 +03:00
or-else 16489432cf fix wallpaper defaults 2026-01-16 11:51:42 +03:00
or-else 84534dfc63 dependencies updated, browserlist config updated, version changed to 0.25.1 2025-12-26 15:51:21 +03:00
or-else 8c3d273c34 missing translations added 2025-12-26 15:24:34 +03:00
or-else b2bae67ed2 version bumped, dependencies updated 2025-12-12 09:13:31 +03:00
or-else 825a6661f3 dependencies updated, version changed to 0.25.0-rc1 2025-11-26 12:02:48 +03:00
or-else c4fa4c6847 dependencies updated, rebuilt 2025-11-08 12:52:29 +03:00
or-else c17a314640 fix dark mode button color 2025-11-08 10:20:30 +03:00
or-else 7fa83d0d3e merge devel 2025-11-07 19:33:14 +03:00
or-else fb11d0f477 dependencies updated 2025-11-07 15:13:18 +03:00
or-else b11d1f6d39 dependencies updated, version changed to 25.0-beta3, rebuilt 2025-11-07 15:09:26 +03:00
or-else 896505e49d fix incoming call colors in dark mode 2025-11-06 18:39:05 +03:00
or-else ad3c6f5d36 fixed position of controls in incoming call 2025-11-06 13:15:57 +03:00
or-else b4ba9ab7d8 slightly stronger indicator of pinned chat 2025-10-28 12:36:05 +03:00
or-else 4cd3e84178 make subscriber count work with older server 2025-10-28 12:32:52 +03:00
or-else 15b41a9bd8 better screenshots 2025-10-27 19:32:33 +03:00
or-else 89c1cc7add minor UI tweak, screenshots updated 2025-10-27 19:07:25 +03:00
or-else 0a1e2d3a3d bump version, update dependencies 2025-10-27 10:27:40 +03:00
or-else 3a2a9f90d0 translations added 2025-10-27 10:13:54 +03:00
or-else ca492e2e93 wallpapers in dark/light mode seem to work now 2025-10-26 21:51:40 +03:00
or-else ec3974f2f0 dark/light mode wallpaper filters partially work 2025-10-26 19:56:55 +03:00
or-else 478fb421bf wallpapers are done except the dark mode 2025-10-22 16:04:18 +04:00
or-else 0f8d94ac2f wallpapers 2025-10-18 15:32:14 +03:00
or-else 1a884d2546 version set to 0.25.0-beta1 2025-10-15 14:52:09 +03:00
or-else 16e688d4c1 fix CallPannel: do not remount between menimized/full screen 2025-10-10 14:58:01 +03:00
or-else 369612afa8 rebuild with the sdk fix 2025-10-08 17:36:11 +03:00
or-else 16bfaddf2c relabel as beta-1 2025-10-08 17:30:31 +03:00
or-else 1085d1caf6 allow messaging while call is in progress (same topic only for now) 2025-10-08 16:49:45 +03:00
or-else abf5b19758 translations added 2025-10-06 21:02:41 +03:00
or-else 44e2fc3a23 rebuilt with the sdk changes 2025-09-30 14:41:02 +03:00
or-else e505b0a607 minor visual adjustments 2025-09-29 20:52:17 +03:00
or-else 8ca7f5cd3e support for showing the number of subscribers 2025-09-29 20:51:51 +03:00
or-else a74ad9dd63 adjust pinned topic background in dark mode 2025-09-24 19:41:20 +03:00
or-else c70e5ebf7e fix late initialization of InfoView component 2025-09-24 19:38:29 +03:00
or-else 679276c2e2 ignore large size of devel modules 2025-09-24 19:37:42 +03:00
or-else 5faacc1f53 pinning topics work as expected 2025-09-23 17:08:34 +03:00
or-else 4818ee7a25 support for pinning topics; truncate text with support for RTL languages 2025-09-20 18:18:01 +03:00
or-else 0fb5132d24 fix error color in dark mode 2025-09-14 17:50:35 +03:00
or-else ed6be52228 merge master 2025-09-05 16:36:03 +03:00
or-else 1dc5ea1655 ux improvemnt: use contact name as title in alert dialog 2025-09-05 16:34:27 +03:00
or-else 6e04638441 make min image zoom non-zero 2025-09-01 14:51:09 +03:00
or-else 89e9b85551 cropper: fix incorrect image positioning after zoom-out 2025-08-21 10:13:43 +03:00
or-else 5bb829a6bc fix slow panning in image cropper 2025-08-20 20:58:07 +03:00
or-else 92611680e4 actually clear uploaded image 2025-08-20 20:57:36 +03:00
or-else 78f31b7cf1 minor visual tweaks 2025-08-20 20:57:09 +03:00
or-else 79a1bc98b2 add config option to send on Enter vs send on CTRL-Enter 2025-08-10 15:25:05 +03:00
or-else 0b05d480c7 change fixed color to light-dark 2025-08-10 12:18:43 +03:00
or-else 7d1011400e merge devel 2025-08-10 12:15:14 +03:00
or-else 3a4794bacb merge master 2025-08-10 10:33:20 +03:00
or-else 56790262f3 rename drafty form response content type to text/x-drafy-fr 2025-08-10 10:30:47 +03:00
or-else fa436b61be remove unused css style 2025-08-09 14:45:14 +03:00
or-else bb1a3f4cea some colors fixed in doc preview 2025-08-09 09:43:40 +03:00
or-else 36cafbe87b dependencies updated 2025-08-09 09:15:18 +03:00
or-else a84a5a677a bump version to 0.24.3 and rebuild 2025-08-07 16:13:47 +03:00
or-else 6bec95e084 automatically change mime-type of json files to application/octeat-stream 2025-08-07 16:12:51 +03:00
or-else 290571f305 transition to application/json+drafty as mime type for form responses 2025-08-07 15:39:43 +03:00
or-else 7ce9470bf3 more consistent jest time mocks 2025-07-30 18:12:55 +03:00
or-else 9eba65b711 italian translation 2025-07-30 17:20:08 +03:00
or-else 0400c804aa dependencies updated, version bumped, rebuilt 2025-07-25 11:44:06 +03:00
or-else 7ea1a24857 more unit tests added 2025-07-25 11:43:45 +03:00
or-else 80d3f5336b dependencies updated, rebuilt, version bumped to 24.1 2025-07-09 15:22:38 +03:00
or-else 6a208cf040 rebuilt 2025-07-09 10:54:31 +03:00
or-else 9cef61d447 color tweaks, support for text size adjustment 2025-07-09 10:54:18 +03:00
or-else 2cb9f69a9d option for adjusting text size, rearranged config components 2025-07-09 10:53:32 +03:00
or-else 276f2bed34 make some components lazy-loaded 2025-07-09 10:51:27 +03:00
or-else 2f05cd0651 translations updated for new features 2025-07-09 10:49:43 +03:00
or-else 1ba3671873 missing files added, minor color tweaks 2025-06-14 10:51:20 +03:00
or-else 6d02cc903c dark mode mostly done 2025-06-13 16:26:48 +03:00
or-else ff60b3902e bump version and rebuild 2025-06-08 16:54:42 +03:00
or-else e641dc0d32 automatically add dir="rtl" to <html> tag when the language is RTL 2025-06-06 10:41:10 +03:00
or-else 8535da995f disable tag editing for user profiles 2025-06-03 19:30:53 +03:00
or-else baad48f044 added missing arabic translations 2025-06-03 18:38:03 +03:00
or-else 72009a693b tweak alias query, update dependencies, change version to -rc3 2025-06-02 17:05:49 +03:00
or-else b3adf15f12 merge devel with arabic support 2025-05-28 17:31:59 +03:00
or-else e8b48dfd84 hook up arabic translation, rebuild 2025-05-28 13:05:16 +03:00
or-else 7c6fb38b02 rebuilt 2025-05-28 12:56:59 +03:00
or-else 28f332f56a cosmetic fixes, rebuilt 2025-05-28 12:56:17 +03:00
or-else 6fdc208ca7 crash due to incorrect reference to topic 2025-05-28 12:55:59 +03:00
Gene 2f74357aac Merge pull request #137 from lamoboos223/devel
added Arabic localization
2025-05-28 12:54:38 +03:00
Lama Alosaimi e692f80bcf removed (,) from service-worker.js 2025-05-24 13:37:34 +03:00
Lama Alosaimi a411b80fc2 added arabic localization 2025-05-24 13:35:46 +03:00
or-else 837013e97c alias cleanup and fixes 2025-05-09 19:19:02 +03:00
or-else 360b37ae13 version updated to 0.24.0-rc2 2025-05-06 11:36:40 +03:00
or-else c69229934e dependencies updated 2025-05-06 11:33:32 +03:00
or-else 8288ad4733 fix alias regex: allow only alphanum as the first char 2025-05-06 10:43:59 +03:00
or-else 7d627edfce make background dark when viewing images 2025-05-01 15:05:42 +03:00
or-else 04279395bc clean up alias validator timer 2025-04-23 21:09:47 +03:00
or-else 14b38af8da merge devel 2025-04-14 09:54:36 +03:00
or-else 3ae00646c7 rebuit with fixed sdk 2025-04-14 09:24:50 +03:00
or-else 43fde20643 fix unread count for topics with all messages deleted 2025-04-10 16:20:33 +03:00
or-else 6527c067c6 all translations done; 2025-03-12 21:11:31 +03:00
or-else 2f374e52ca translations: de, es, fr, ro, ru, uk 2025-03-12 20:23:33 +03:00
or-else b1acfecd4f fixing translations 2025-03-12 19:40:33 +03:00
or-else 5448f10b1a cosmetic alias-related bugs 2025-03-09 10:28:35 +03:00
or-else 3b78971a39 fix menu [delete for all] 2025-03-06 16:41:25 +03:00
or-else 11436a80ee support fro unique aliases, https://github.com/tinode/chat/issues/446 2025-03-04 13:05:51 +03:00
or-else 3567f323c7 add tests for lib/utils.js 2025-03-03 21:07:40 +03:00
or-else 6f05fd102f merge devel 2025-02-23 11:05:57 +03:00
or-else 4897d63029 bug fix: incorrect QR code in topic info 2025-02-23 11:05:00 +03:00
or-else 5e92189b97 ability to hard-delete p2p messages, debugged pinned messages 2025-02-22 18:55:53 +03:00
or-else 30bf77d4ba sending messages to self works 2025-02-16 22:06:02 +03:00
or-else b2772924bd more vc cleanup 2025-02-15 20:41:52 +03:00
or-else 6c1c8cf336 removed incomplete video conferences 2025-02-14 21:40:44 +03:00
or-else f91d8a979d implement touch swiping pinned messages 2025-02-14 17:38:49 +03:00
or-else 17b91c4bb8 prevent accidental clicks from tiggering scroll 2025-02-09 18:46:36 +03:00
or-else a3a4fe7505 dependencies updated, do not update react to 19 - buggy 2025-02-08 21:54:45 +03:00
or-else 02e8bd2f74 merge devel 2025-02-08 19:50:34 +03:00
or-else bc8f20977e changed browserslist config, improved browser sleep detection 2025-02-07 20:59:45 +03:00
or-else 12a97cacc8 rebuild for 0.23.0 release 2025-01-16 18:11:49 +03:00
or-else 4052bb72b6 dependencies updated, version bumped 2025-01-16 17:36:36 +03:00
or-else 458b519845 reconnect when PC wakes up from sleep 2024-12-14 20:44:44 +03:00
or-else 07a22564c2 add thai and ukrainian language codes to package.json 2024-05-30 16:12:14 -07:00
or-else 31a95676b7 version bumped to 23.0-rc1, dependencies updated 2024-05-30 16:09:28 -07:00
or-else ba81261ee8 rebuilt with correct sdk 2024-05-18 18:07:14 -07:00
or-else 944cf32bb9 Merge branch 'master' into devel 2024-05-08 15:23:42 -07:00
or-else ed34a46865 make emoji-only messages larger for 1-5 emojis 2024-05-08 13:39:10 -07:00
or-else a4befc4cd3 make font larger for emoji-only messages 2024-05-01 14:36:18 -07:00
or-else 7b15cf1f19 Merge branch 'devel' 2024-04-29 15:53:05 -07:00
or-else df03509029 bump version to 22.13, dependencies updated 2024-04-29 15:17:46 -07:00
or-else 354ce3b8a3 bug fix: do not alter data or blob URLs with [asatt] 2024-02-10 11:33:36 -08:00
or-else 66192a8d53 google decided that assentlins have to be updated 2024-02-08 13:41:11 -08:00
or-else 7f2a89d86c version bumped to 22.12, dependencies updated 2024-02-05 18:51:02 -08:00
or-else 9eaa172030 add QR code display 2024-02-02 16:26:49 -08:00
or-else f932b973f8 ensure asatt=1 is aded to image download URL to set Content-Disposition: attachment 2023-12-21 16:06:19 -08:00
or-else 40916e2fc3 repo url fixed 2023-12-15 15:32:04 -08:00
or-else 5faedc35f3 bump version, update dependencies, and rebuild 2023-12-15 14:27:32 -08:00
or-else de32da49f3 make sure only safe schemes are clickable in URLs 2023-12-15 14:27:05 -08:00
or-else f5714b3b69 fix a security issue with absolute URL appearing as relative du to backslashes 2023-12-15 14:25:44 -08:00
or-else 1155888ced dependencies updated 2023-10-23 17:30:17 -07:00
or-else d128cec90d add Thai and (missing) Ukrainian loaders, mention Thai translation 2023-09-25 14:13:17 -07:00
Gene 3a8ee3e2dd Merge pull request #135 from Bukharney/master
Added Thai translation
2023-09-25 14:05:31 -07:00
Jirapat Lakma 1164d3a5bd added Thai translation 2023-09-25 15:34:10 +07:00
Jirapat Lakma fb2a39280a add Thai translation 2023-09-25 11:25:59 +07:00
or-else a1faba6b0c bump version to 0.22.10 2023-08-16 09:30:40 +03:00
or-else ca24b82f54 mention ukrainian translation 2023-08-14 16:41:12 +03:00
or-else 395edb3e4c rebuilt with ukrainian translations 2023-08-14 16:37:20 +03:00
or-else 691879b2b2 ukrainian translations 2023-08-14 16:36:01 +03:00
or-else 8187f09d26 rebuilt with the fix for #131 2023-08-14 11:15:09 +03:00
or-else e82951722d Merge branch 'devel' of https://github.com/tinode/webapp into devel 2023-08-13 09:59:27 +03:00
or-else 1042b9c39a rebuilt with fix for #129, dependencies updated 2023-08-13 09:59:20 +03:00
Gene 3177b667b8 Merge pull request #130 from tandr3w/master
Fix sending of "read" notifications after backing out of a group on mobile
2023-08-12 09:16:32 +03:00
Andrew 5fb85ce822 Fix sending of "read" notifications after backing out of a group on mobile.
https://groups.google.com/g/tinode/c/DMmeN79dZe0
2023-08-11 15:46:46 -04:00
or-else 500d72702a update dependencies, bump version 2023-07-08 08:44:12 -07:00
Gene 31b13401a2 Merge pull request #125 from tinode/vc
Video conferencing skeleton.
2023-06-29 08:13:13 -07:00
aforge 338e0f6023 Merge branch 'vc' of https://github.com/tinode/webapp into vc 2023-06-29 03:57:12 -07:00
or-else df4b9c312a fix height of title panel in image preview 2023-06-27 19:06:29 -07:00
or-else 07d32dbfd8 fix unnecessary horizontal scrolling 2023-06-15 15:40:08 -07:00
or-else 0d887b73a0 changed border color of the current speaker 2023-06-15 15:31:07 -07:00
or-else 685a1cd2a4 remove this.participants, clone object instead 2023-06-15 15:20:39 -07:00
or-else 8f9f41efe5 removed unnecessary React component <> 2023-06-15 14:03:04 -07:00
or-else dde4e565c8 allow longer caller names 2023-06-15 13:11:56 -07:00
or-else 94575b5a07 pass viewport to call panel 2023-06-15 13:11:23 -07:00
or-else 3bf822170c clean up scrolling of carousel 2023-06-15 12:06:39 -07:00
or-else deca04ed3e visual style fixes for video confs 2023-06-14 18:46:49 -07:00
or-else 97908ec84b move refs from state to object, bug fix 2023-06-14 10:11:24 -07:00
or-else 20dcb48ec5 all remaining translations added 2023-06-10 19:52:03 -07:00
or-else 7bad07f717 UI work on conference calls, translations 2023-06-10 19:00:52 -07:00
or-else 2f85f01a1a make livekit external 2023-06-10 18:10:42 -07:00
or-else a86d07f854 merge next 2023-06-10 12:14:59 -07:00
or-else 414129d968 comments corrected 2023-06-10 12:00:16 -07:00
or-else 5d664b86cb merge devel 2023-06-09 17:16:22 -07:00
or-else d6c904d38a dependencies updated and rebuilt 2023-06-08 19:02:25 -07:00
Gene ab04a872e2 Merge pull request #128 from sun2008mt/master
fix Error: ENOENT: no such file or directory, open './src/i18n.min/src\i18n\zh.json' on windows platform
2023-05-29 09:24:45 -07:00
marc 7f0a16d701 fix Error: ENOENT: no such file or directory, open './src/i18n.min/src\i18n\zh.json' on windows platform 2023-05-29 23:09:11 +08:00
or-else 43e89e2119 update dependencies and rebuild 2023-05-25 13:44:46 -07:00
or-else baf211ff82 fix for /tinode/chat/issues/861 2023-05-17 18:31:26 -07:00
or-else c5c55fd9fd force message view refresh on subscription update, #127 2023-05-12 13:40:37 -07:00
or-else 8403e3e9b4 fix calculation of visible range 2023-05-11 16:57:22 -07:00
or-else fd08d19939 fix very long processing time in scroll handler 2023-05-11 15:43:31 -07:00
aforge b14717bb48 Merge branch 'next' into vc 2023-05-10 16:36:33 -07:00
or-else f969c04c47 npm audit fix 2023-05-10 14:44:59 -07:00
or-else 2fe35f9ded merge devel 2023-05-10 14:43:49 -07:00
or-else 852a1a7c4e fix for https://github.com/tinode/chat/issues/858 2023-05-10 13:20:40 -07:00
aforge f7120d824d Some style fixes. 2023-05-09 14:23:26 -07:00
or-else c15092e850 work in progress on disappearing deleted topic 2023-05-09 14:23:21 -07:00
or-else 7ca827559b provide default value for the field 2023-05-08 19:07:35 -07:00
or-else f7beabacf9 fix for https://github.com/tinode/chat/issues/861 2023-05-08 18:45:44 -07:00
or-else 848bb19f76 bump version 2023-05-08 17:19:32 -07:00
aforge 981ef684a7 Add livekit-client dep. 2023-05-08 17:01:42 -07:00
aforge 55fc4356da Remove '2' from VCPanel event handler names. 2023-05-08 16:52:08 -07:00
or-else c17118d157 fix a bunch of problems with credential validation 2023-05-08 14:08:21 -07:00
or-else 95e9891825 add extra spacing in error panel 2023-05-08 10:59:46 -07:00
or-else 0984c73c01 rebuilt with updated sdk 2023-05-06 10:42:52 -07:00
aforge 0425bd4348 Video conferencing skeleton. 2023-05-03 22:50:51 -07:00
or-else f9e7a71920 fix display of pinned messages 2023-05-02 10:28:30 -07:00
or-else ba72fe7602 rebuilt with correct sdk 2023-04-28 17:18:28 -07:00
or-else 6da591beb1 remove unused missingRanges 2023-04-28 17:16:38 -07:00
or-else 16c9e2bb23 dependencies updated, rebuilt 2023-04-28 13:08:57 -07:00
or-else 3689b5d527 another fix for #124 2023-04-28 12:48:16 -07:00
or-else c277c16a53 mostly functional 2023-04-27 17:44:20 -07:00
or-else 1bc3e15da3 merge devel 2023-04-27 11:43:30 -07:00
or-else 3ac895ec66 rebuilt with correct sdk version 2023-04-27 11:41:11 -07:00
or-else 08bdc7bc08 allow automatic subscription to a new p2p topic 2023-04-27 11:00:22 -07:00
or-else 03a137da4a fractional fetching, wip 2023-04-27 09:41:49 -07:00
or-else 8df6413705 merge devel 2023-04-25 12:34:25 -07:00
or-else 5d56c17128 another fix for #124 2023-04-25 12:31:07 -07:00
or-else 87c74f9396 rebuilt with sdk 2023-04-25 10:18:44 -07:00
or-else 2b7c80ef2d removed unused files 2023-04-24 14:36:05 -07:00
or-else 823215aea6 merge devel 2023-04-24 14:34:25 -07:00
or-else 5ad734cdea some new files added 2023-04-24 14:32:21 -07:00
or-else 48405bcb94 fix for #124 2023-04-24 14:25:48 -07:00
or-else 0f9b88e64e sdk with fractional message fetching (wip) 2023-04-24 11:38:23 -07:00
or-else 10a827504b always load pinned messages 2023-04-18 17:07:38 -07:00
or-else 1d24e9135b merge devel, update dependencies, rebuild 2023-04-16 11:47:47 -07:00
or-else a5c190f6db sync version with the backend 0.22.6 2023-04-15 10:43:33 -07:00
or-else f61f68d2ac added another missing translation 2023-04-14 20:21:40 -07:00
or-else 2c5705c3ae translations updated 2023-04-14 20:12:26 -07:00
or-else c4547bbeb4 partial update of translations 2023-04-14 19:56:23 -07:00
or-else e7296d5d14 rebuilt 2023-04-14 19:38:21 -07:00
or-else 364cd2c488 merge devel 2023-04-14 19:34:27 -07:00
or-else 855f871041 rebuilt with SDK fix for downloads 2023-04-14 19:20:25 -07:00
or-else 8dd32a341b fix some issues with audio recording and playback in safari #121, #122 2023-04-14 17:47:35 -07:00
or-else ebe47b345d pix editing ofpinned messages 2023-04-05 10:14:07 -07:00
or-else 454dc204c8 minor visual tweaks 2023-03-25 11:13:04 -07:00
or-else d0c88fbd0d finishing touches, all seems to work 2023-03-24 15:15:40 -07:00
or-else e68ebc8336 works as expected on desktop, mobile needs work 2023-03-24 13:56:24 -07:00
or-else cca97d410e widget for pinned messages 2023-03-23 18:22:30 -07:00
or-else eafd87fb17 make bg change on click more subtle 2023-03-23 18:16:32 -07:00
or-else 71ee3fbec4 widget for showing pinned messages (wip) 2023-03-23 18:09:11 -07:00
or-else 11fe81fdf1 adding pinned messages (wip) 2023-03-22 17:50:33 -07:00
or-else 9640c604b9 Merge branch 'devel' into next 2023-03-22 11:00:37 -07:00
or-else 6273b3287f rebuilt with correct version of the sdk 2023-03-22 09:22:39 -07:00
or-else 9c5e3a3eca dependencies updated 2023-03-22 09:19:31 -07:00
or-else 9098c1f6a6 merge devel 2023-03-20 17:21:25 -07:00
or-else a2439be540 Merge branch 'devel' into next 2023-03-20 16:12:41 -07:00
or-else a2fb3fe1a0 rebuilt 2023-03-20 16:12:36 -07:00
or-else 560e3369e4 rebuilt 2023-03-20 16:11:43 -07:00
or-else 491175cecc dependencies updated 2023-03-20 16:10:36 -07:00
or-else 6d96d327f1 fix backward scrolling on fetching older messages 2023-03-20 11:35:01 -07:00
or-else 6ee2851943 rebuilt 2023-03-19 15:14:24 -07:00
or-else 192a3d4ec2 don't crash if FIREBASE_INIT is not defined 2023-03-19 15:14:00 -07:00
or-else c6f7911c82 dependencies updated, version to 0.22.0, rebuilt 2023-03-15 10:43:29 -07:00
or-else 9a1f0c734a dependencies updated 2023-03-15 08:41:22 -07:00
or-else 52f879490e fix for tinode/chat/issues/838 2023-03-14 18:14:33 -07:00
or-else 8df3c34cf4 moved hiding components to the top level 2023-03-14 15:54:22 -07:00
or-else 2fb6a9bb0a escape key closes image, video, or document preview 2023-03-14 14:23:16 -07:00
or-else 5ae58fb2a6 allow message input textarea height to grow with more text 2023-03-14 13:53:21 -07:00
or-else 43798ea1d9 minor tweaks 2023-03-14 09:44:54 -07:00
or-else 323042bd96 rebuild 2023-03-03 15:56:09 -08:00
or-else 172b9eb26d removed duplicate styling 2023-03-03 15:52:53 -08:00
or-else 1b90c613f7 fix drag and drop in firefox 2023-03-02 16:19:13 -08:00
or-else c6b4460389 merge devel and bump version to 0.22.0-rc1 2023-03-02 13:32:42 -08:00
or-else 497318b722 updated dependencies and bumped version 2023-03-01 16:49:14 -08:00
or-else 423f53880d Merge branch 'devel' 2023-03-01 14:34:32 -08:00
or-else a93ede25e9 make date bubble lighter 2023-02-24 18:50:09 -08:00
or-else b4d3e0274f fix color of the date bubble 2023-02-24 16:23:22 -08:00
or-else 4f116ab3e6 replace box-shadow with filter: drop-shadow to remove artifacts 2023-02-24 15:39:08 -08:00
or-else 75b2880b63 bump version 2023-02-20 16:18:28 -08:00
or-else 1149980a47 rebuilt 2023-02-20 16:05:36 -08:00
aforge 87b48cd67d Fix strutil.secondsToTime(). 2023-02-16 13:26:55 -08:00
aforge 4be09a034e Calls: create data channel only on the caller's side.
Removes redundancy and a memory leak (and thus an error upon closing).
2023-02-16 13:25:15 -08:00
aforge 3c0b57333c Calls: create data channel only on the caller's side.
Removes redundancy and a memory leak (and thus an error upon closing).
2023-02-15 17:55:29 -08:00
Gene 0c9b23eee9 Merge pull request #118 from sun2008mt/master
Update zh.json
2023-02-09 08:33:58 -08:00
Marc SUN d11f94095f Update zh.json 2023-02-09 18:14:08 +08:00
or-else 133ed3cc53 merge devel 2023-02-08 13:40:09 -08:00
or-else 7bc50fac36 handing of "busy" webrtc status 2023-02-08 13:30:50 -08:00
aforge fbdd017d38 Fix strutil.secondsToTime(). 2023-02-06 22:35:48 -08:00
or-else 8fdb4a8a4c bump version to 0.21.1 and rebuild 2023-02-06 10:22:00 -08:00
or-else e1b1ae503f fix issues with video uploading and display 2023-02-06 09:58:56 -08:00
or-else 7b44913724 fix display of broken videos 2023-02-06 09:56:50 -08:00
or-else 7e3b2fc254 switch tinode-sdk from browserify to webpack 2023-02-06 09:54:12 -08:00
aforge fc6a8fbafc Do not crash in MessagesView.handleMessageUpdate if topic gets unset. 2023-02-06 09:51:53 -08:00
or-else 111b9d8641 rebuilt with the sdk fix 2023-02-05 17:49:31 -08:00
or-else eeea27ca82 fix issues with video uploading and display 2023-02-05 17:08:41 -08:00
or-else fc2ff79329 fix display of broken videos 2023-02-05 13:46:08 -08:00
or-else f755314c3b rebuilt 2023-02-05 12:43:17 -08:00
or-else caf02056ab merge next 2023-02-04 21:23:53 -08:00
or-else 4b26d5ed40 rebuilt 2023-02-04 21:23:02 -08:00
aforge 885d9932e2 Do not crash in MessagesView.handleMessageUpdate if topic gets unset. 2023-02-04 17:58:56 -08:00
or-else f6c355498b switch tinode-sdk from browserify to webpack 2023-02-04 14:31:36 -08:00
or-else 455f0f5c67 implement AsYouType in phone editing 2023-02-04 10:54:49 -08:00
or-else e99370683b merge next 2023-02-04 10:04:59 -08:00
or-else 9fb1551bef fix npm run clean 2023-02-04 09:55:10 -08:00
or-else 8d40301ddf Merge branch 'next' into devel 2023-02-03 16:56:58 -08:00
or-else 93fb2b8bca added missing translation 2023-02-03 11:35:36 -08:00
or-else 97178a603f dependencies updated (except firebase - the latest is broken), version bumped 2023-02-03 11:13:59 -08:00
or-else f13c1d21c0 merge next 2023-02-01 15:56:56 -08:00
or-else 7e28c8693c Merge branch 'devel' into next 2023-02-01 15:54:29 -08:00
or-else c95181b0cf cleanup of umd/* 2023-02-01 15:54:15 -08:00
or-else b2c5051d31 cleanup of umd/* 2023-02-01 15:53:18 -08:00
or-else 6e46a8dfda fixed + cleanup 2023-02-01 15:37:44 -08:00
or-else 99c9a698a0 password reset seems to work correctly 2023-02-01 14:38:57 -08:00
or-else aa5e9ef5c7 adding credential editing 2023-01-27 16:18:23 -08:00
or-else 4f6d617829 merge next 2023-01-27 09:03:09 -08:00
or-else 05c5ffa43c fix positioning of the peer's name tag in video calls 2023-01-26 17:32:54 -08:00
or-else 9d7bfec6d6 adding new files to git 2023-01-26 15:59:10 -08:00
or-else d8f7a41bdd password reset works, missing translations 2023-01-26 15:58:33 -08:00
or-else 6c36324ed4 phone number widget fixes 2023-01-25 18:41:19 -08:00
or-else e2f3201ddb phone editing widget works 2023-01-25 17:08:07 -08:00
or-else 34f17f45b1 Merge branch 'next' into phone 2023-01-25 11:32:24 -08:00
or-else 1cf4722830 fix: broken alert view 2023-01-25 11:29:26 -08:00
or-else 7c4327ac71 bump version to 0.21.0-rc2 2023-01-24 09:01:07 -08:00
or-else 48caa605c7 work in progress 2023-01-24 08:53:08 -08:00
aforge edb1383d60 Merge branch 'next' of https://github.com/tinode/webapp into next 2023-01-23 13:33:11 -08:00
aforge 2a653f2299 Fix missing audio. 2023-01-23 13:27:50 -08:00
or-else eba83fdec8 support for registering with a phone number 2023-01-23 10:48:43 -08:00
or-else 5f173dd395 merge next 2023-01-23 10:24:48 -08:00
or-else 2115f6d638 merge devel 2023-01-23 10:23:04 -08:00
or-else 5228d44ade rebuilt 2023-01-23 10:19:43 -08:00
or-else ce19ebbbdd fix for https://github.com/tinode/chat/issues/825 2023-01-23 10:18:22 -08:00
or-else 5e60a5e04d call parent on lost focuse 2023-01-23 10:00:07 -08:00
or-else 9f523b54a4 attempt to integrate phone editing 2023-01-22 20:04:13 -08:00
or-else 6c13943f1a forgot to add new files 2023-01-22 19:26:01 -08:00
or-else b0bf93a4d9 phone number editing controls are functional 2023-01-22 18:57:19 -08:00
aforge b766e827c9 Set data channel in state. 2023-01-18 14:10:49 -08:00
or-else 424a8b64bf remove autoFocus from message input field on mobile, #111 2023-01-17 12:50:58 -08:00
or-else 9108efc8c9 merge google analytics branch 2023-01-14 12:54:53 -08:00
or-else f94c3612be Merge branch 'next' into devel 2023-01-14 11:47:03 -08:00
or-else 95ddfd21eb version bumped to 0.21.0-rc1, rebuilt 2023-01-14 11:46:50 -08:00
Gene ae5618c072 Merge pull request #108 from tinode/audio-only
Audio-only calls
2023-01-14 11:09:51 -08:00
or-else d083a286df correct comment wording 2023-01-13 19:00:26 -08:00
or-else e0db170aec merge next 2023-01-13 17:28:06 -08:00
or-else e9d0f0f1f3 remove styling from focused buttons 2023-01-13 17:25:57 -08:00
or-else 08d4c08cbc optional integration of google analytics 2023-01-12 20:15:08 -08:00
or-else 15d24c6607 do not logout of server errors 500 or timeouts 2023-01-11 09:03:48 -08:00
or-else c085ef8ce1 make disabled buttons more obviously disabled 2023-01-10 17:59:12 -08:00
or-else 2d76befea1 fix missed calls when topic is not in focus 2023-01-10 17:18:34 -08:00
or-else 20c54fb7e3 removed debug logging 2023-01-10 16:44:03 -08:00
aforge c5834fad4d Merge pull request #109 from tinode/mute-video
Mute/unmute video properly.
2023-01-10 16:29:57 -08:00
or-else 640704ee2a bug fixes 2023-01-10 16:28:32 -08:00
aforge 18dd37b402 Fix remote video screen flickering. 2023-01-10 13:00:49 -08:00
aforge 54d6719307 Fix race between setState and PeerConnection events. 2023-01-09 20:23:26 -08:00
aforge 255e7fda98 Style + unmute delay fix. 2023-01-09 17:52:10 -08:00
aforge 1a6e8c8e74 Mute/unmute video properly.
Now muting turns the camera off instead of sending blank frames.
2023-01-09 16:08:44 -08:00
or-else b642d05a10 merge next 2023-01-09 11:56:46 -08:00
or-else 920619a988 make call buttons round for consistency with other apps 2023-01-09 11:55:56 -08:00
or-else acc24b552d simplify a bit and remove debug logging 2023-01-09 11:21:30 -08:00
or-else ba94788fbe fix service worker and notifications init for FF and Safari 2023-01-09 11:18:21 -08:00
or-else 2758bb9293 dependencies updated, version bumped to -beta2 2023-01-08 12:39:32 -08:00
or-else 4635456c1a rebuilt with changed SDK 2023-01-07 14:57:41 -08:00
or-else 89f9821255 audio-only calls kind of work 2023-01-07 14:56:07 -08:00
or-else 8690ae6a1b merge next 2023-01-06 10:48:25 -08:00
or-else 483d7ef58d Merge branch 'next' into devel 2023-01-05 18:46:19 -08:00
or-else e1d7ebcc22 rebuilt with the sdk 2023-01-05 18:46:16 -08:00
or-else 45fbff1b4a rebuilt with sdk fix: incorrect reference when replying to edited message 2023-01-05 17:20:20 -08:00
or-else f99f5a223f assign file size for in-band attachments 2023-01-05 13:22:08 -08:00
or-else c0e4cd4965 rebuilt with SDK fix: ensure edits cannot change message sender 2023-01-05 12:33:42 -08:00
Gene 7d796e3dae Merge pull request #106 from tinode/next
Message editing, video messages
2023-01-05 09:48:35 -08:00
or-else 1d3a9040cb rebuilt with the sdk fix for previews of forwarded messages 2023-01-04 21:00:12 -08:00
or-else 29cce0a4cc rebuilt with the sdk fix for previews of forwarded messages 2023-01-04 20:59:14 -08:00
or-else c59c3eeb0b merge next 2023-01-04 18:09:16 -08:00
or-else cab80e4d62 translations for the new error message 2023-01-04 18:04:15 -08:00
or-else f7c5322e36 clear "reply/editing/forwarding state when switching topics 2023-01-04 17:59:45 -08:00
or-else cb64e1f247 fix handling of inline videos 2023-01-04 17:52:28 -08:00
or-else afee6b5dd9 clear edited message text when switching topics 2023-01-04 13:58:19 -08:00
or-else 9f01eab324 disable editing quoted messages 2023-01-04 13:05:03 -08:00
or-else 80d48077c9 initial pass on audio-only calls 2023-01-04 12:47:02 -08:00
or-else 1bc6f87844 use "in progress" instead of 0:00 for calls in progress 2023-01-03 17:34:53 -08:00
or-else 0ea112b145 Merge branch 'devel' into next 2023-01-02 17:45:07 -08:00
or-else 768edd744d fallback to fixed size when video has no size info 2023-01-02 13:18:26 -08:00
or-else 1aadf4e7bd rebuilt with correct sdk version 2022-12-31 16:01:55 -08:00
or-else 2d92e475b5 dependencies updated and rebuilt 2022-12-31 13:55:38 -08:00
or-else a28c3eb5cd minor vidual correction 2022-12-31 13:48:57 -08:00
or-else 97208e1448 bum version to 0.20.4 2022-12-30 12:26:46 -08:00
or-else 9fbc601870 fix preview of received videos 2022-12-30 10:20:13 -08:00
or-else 4489646144 fix for a couple of minor problems 2022-12-20 18:34:20 -08:00
or-else 261b2dbdd7 minor color tweak 2022-12-16 09:28:13 -08:00
or-else 224323f443 merge devel 2022-12-16 09:15:07 -08:00
or-else 6d031f033e another small tweak to badges 2022-12-16 09:13:27 -08:00
or-else c38e1832dc tweak topic type badges 2022-12-16 09:11:05 -08:00
or-else 13036da36b more cleanup of video attachments 2022-12-14 21:05:40 -08:00
or-else f5c628fbb5 new translations 2022-12-14 19:39:10 -08:00
or-else 5673d82b33 replies to video recordings work 2022-12-14 19:24:59 -08:00
or-else 8c3287941a video works although may have bugs 2022-12-14 16:50:00 -08:00
or-else 830d15f7ec rebuilt 2022-12-14 14:27:56 -08:00
or-else 4e42d96d66 fix video message previews 2022-12-14 14:27:33 -08:00
or-else ed77f48189 move dnd to MessagesView, otherwise problems with layout 2022-12-14 14:26:47 -08:00
or-else ed3107405f make drag n drop prettier 2022-12-14 10:54:39 -08:00
or-else 03e87697cf merge with drag-n-drop 2022-12-13 17:38:07 -08:00
or-else bccf0d3b6b video uploading + preview in bubbles works 2022-12-13 17:37:04 -08:00
aforge 36b7d1b721 Merge pull request #104 from tinode/drag-and-drop
Drag & drop for attaching files.
2022-12-13 10:37:32 -08:00
aforge 2294410fb9 Merge branch 'next' into drag-and-drop 2022-12-13 10:36:47 -08:00
aforge 10140c6450 Move file attach DnD to MessagesView. 2022-12-13 00:20:36 -08:00
or-else 489a05663a adding widget for displaying video previes inside message bubbles 2022-12-12 22:09:17 -08:00
or-else f31a3ebdcc .gitattributes + *.min.css binary 2022-12-12 22:06:29 -08:00
or-else 6360708255 working on display of images 2022-12-12 22:05:08 -08:00
or-else 24a87f3deb videos work but with bugs 2022-12-12 18:37:59 -08:00
or-else a8d7e6be50 wip: adding support for video recordings 2022-12-12 16:48:15 -08:00
aforge b434c4ef17 Drag & drop for attaching files. 2022-12-12 16:32:48 -08:00
or-else 0d33c0f0a8 removed unused variables 2022-12-12 13:40:52 -08:00
or-else e95aea17be make date dividers reasier to read 2022-12-11 17:43:07 -08:00
or-else d820da2ee2 build with the SDK fix (timestamps of edited messages) 2022-12-10 18:37:07 -08:00
or-else 692e6e1d8f send "recording" notifications when audio is being recorded 2022-12-10 18:17:03 -08:00
or-else 6aca7e527e removed unused import 2022-12-08 14:07:24 -08:00
or-else ae34795c2d removed legacy translations 2022-12-08 14:05:28 -08:00
or-else 310b352a08 disable editing of some types of messages 2022-12-08 14:03:10 -08:00
or-else b8e9ca8d0a removed support for display of deleted message ranges 2022-12-08 12:32:30 -08:00
or-else 7731c6700b fix for https://github.com/tinode/chat/issues/808 2022-12-07 19:17:18 -08:00
or-else 563f82d265 merge devel 2022-12-07 18:29:28 -08:00
or-else 10b24cc16e updated dependencies, rebuilt, version bumped to 0.20.3 2022-12-06 15:19:27 -08:00
or-else f6cd3048e1 on-demand translation loading works now 2022-12-05 17:18:47 -08:00
or-else 4ad723b854 the app restored: "runtimeChunk: single" was the problem 2022-12-05 15:26:17 -08:00
or-else 4af7a39527 rebuilt 2022-12-05 14:03:13 -08:00
or-else 0e11ae9382 rebuilt 2022-12-05 13:58:48 -08:00
or-else a2014826e3 rebuilt, does not work 2022-12-05 09:58:38 -08:00
or-else 3a002f224d remove topLevelAwait experiment 2022-12-05 09:33:56 -08:00
or-else fa78e678b9 rebuilt 2022-12-05 09:24:38 -08:00
or-else 5fdcc65b5b does not work 2022-12-04 14:24:05 -08:00
or-else 476452c59e simplify package.json files section 2022-12-04 11:52:04 -08:00
or-else b0c0790b91 added new code chunks to package.json 2022-12-04 11:47:27 -08:00
or-else 9d83dee14c rebuilt with the webm fix & code splitting 2022-12-04 11:42:32 -08:00
or-else 09223c9efc adding newly split chunks 2022-12-04 11:25:42 -08:00
or-else d0a23b0792 use webm-duration-fix to add SeekHead, use code splitting 2022-12-04 11:25:06 -08:00
or-else d54e4a8153 does not work 2022-12-02 14:37:26 -08:00
or-else 57a062266f npm prune 2022-12-01 14:18:19 -08:00
or-else ed71c6bb6e extermalize fix-webm-duration, remove unused webrtc-adapter 2022-12-01 14:17:21 -08:00
or-else 307665d8ec merge devl 2022-11-26 14:23:01 -08:00
or-else 297aef7bf1 consisten avatar handling in new group creation 2022-11-26 14:21:07 -08:00
or-else 161f544442 explicitly set audio recording bit rate 2022-11-26 14:20:43 -08:00
or-else 22e782fa2d Revert "explicitly set audio recording bit rate"
This reverts commit 7135553e42.
2022-11-26 14:16:15 -08:00
or-else 7135553e42 explicitly set audio recording bit rate 2022-11-26 14:15:43 -08:00
or-else 1913ed0c11 fix for #102, part 2 2022-11-23 10:07:20 -08:00
or-else 6de41f4988 fix for #102, part 1 2022-11-23 10:00:41 -08:00
or-else c5e744aacf fix deletion of edited messages 2022-11-21 16:11:12 -08:00
or-else 983de97d7f removed StrictMode due to issues with double mounting of components 2022-11-20 16:20:53 -08:00
or-else 7236c913da shorten edit preview 2022-11-20 13:36:08 -08:00
or-else 72ec3b51a0 edited for code style consistency 2022-11-19 17:14:43 -08:00
or-else 3653453897 a couple of bugs in message editing 2022-11-19 16:43:57 -08:00
or-else d32c1636ce editing once works, editing twice does not 2022-11-19 16:21:24 -08:00
or-else a99ad81196 all dependencies updated to latest, including breaking changes 2022-11-17 11:43:28 -08:00
or-else eec52a590c bumped version to 0.20.2, dependencies updated 2022-11-16 09:33:33 -08:00
or-else c7b8ce11e0 force reconnect if countdown is negative (computer woke up from sleep) 2022-11-14 17:20:07 -08:00
or-else 2156aa0877 wrap long text in <tt> 2022-11-06 12:08:02 -08:00
or-else 966dec9654 rebuilt 2022-11-02 17:48:08 -07:00
or-else 0b2ab61422 dependencies updated, version bumped to 0.20.1 2022-11-02 17:46:21 -07:00
or-else 092b4697a9 releasing 0.20.0 2022-10-11 11:27:02 +03:00
or-else 6c5fe836f4 rebuilt with fix for #101 2022-09-22 14:45:43 +03:00
or-else 40b99f7332 update dependencies, bump version to 0.20.0-rc2, rebuild 2022-09-06 15:22:27 -07:00
or-else d45a141704 fix avatar upload on signup 2022-09-01 11:12:09 -07:00
or-else ee106a17f6 incomplete fix for authentication after credential validation, #100 2022-08-29 18:37:10 -07:00
or-else 22320808fc fix frozen validation input field 2022-08-27 12:51:52 -07:00
or-else 5006a6fb34 partial fix for avatar uploads 2022-08-25 18:50:11 -10:00
or-else df203f48bd fix display of a validation success message 2022-08-25 15:02:11 -10:00
or-else 0e53234d02 add "success" message when credential was validated, #100 2022-08-25 13:45:37 -10:00
or-else 14a4725c94 Merge branch 'next' of https://github.com/tinode/webapp into next 2022-08-25 13:03:44 -10:00
or-else 740cb7faf8 partial fix for #100 2022-08-25 13:03:31 -10:00
aforge 384955777d Update host when user hits Enter in host selector. 2022-08-24 12:36:10 +03:00
or-else 8b80f59d3a fix for a crash on a typo in a name of a variable 2022-08-23 18:12:51 -10:00
or-else 6a6e3ad754 restrict app width on very wide monitors 2022-08-22 12:11:18 -10:00
or-else d15aa9372d minor visual tweaks and missing translation 2022-08-22 12:05:32 -10:00
or-else 5144f581e7 better handling of non-default hosts, like connecting to sandbox from localhost 2022-08-15 15:06:33 -07:00
or-else 8250c5f372 enable connections to sandbox from localhost 2022-08-14 16:01:09 -07:00
or-else ae26e0ba3d added "Helping out" section 2022-08-09 18:04:32 -07:00
or-else 6a52591e1a updated dependencies and bumped version to -rc1, rebuilt 2022-08-09 16:02:19 -07:00
or-else 81afca1efe merge with remote 2022-08-09 15:16:43 -07:00
or-else eac59ea11f rebuilt with the sdk rc1 2022-08-09 15:14:45 -07:00
or-else 2f3a066838 disable video calling when no ICE config 2022-08-09 14:37:21 -07:00
aforge 3922db3aa6 Update call status text. 2022-08-02 13:43:57 +03:00
aforge 9cd778534a Add onIceCandidateError handler for video calls. 2022-07-26 01:07:26 +03:00
aforge 69282bab89 Add missing Tinode.onData and .onInfo handlers. 2022-07-25 12:29:03 +03:00
or-else af08392260 merge conflict resolved 2022-07-19 08:55:44 -07:00
or-else a90f1f4fef fix crash on missing AccessMode module 2022-07-19 08:54:49 -07:00
aforge 781009b745 Drop unsent video call messages when synching topics. 2022-07-17 17:03:27 +03:00
or-else 6357f9227e fix password focus, bump version to -beta3 2022-07-15 11:41:51 -07:00
aforge 7dfb90d8f3 Call establishment improvements.
1. More orderly offer-answer exchange sequence.
2. Cache remote ICE candidates that arrive before call establishment is complete (we use trickle ICE).
2022-07-06 16:44:19 +03:00
or-else 7348fdef7f rebuilt with fixed sdk 2022-07-02 15:11:16 -07:00
or-else e42de09504 rebuilt with fixed sdk 2022-07-02 15:09:51 -07:00
or-else 5c842ef212 bump version and rebuild 2022-06-30 11:29:04 -07:00
or-else c94b397222 merge fixes from devel 2022-06-30 11:05:53 -07:00
or-else c013885bec rebuild with the SDK fix + better error reporting 2022-06-30 10:54:33 -07:00
or-else 4a2667015f merged fix of password change 2022-06-29 18:40:16 -07:00
or-else e209ef2483 fix crash on attempt to change password 2022-06-29 18:39:00 -07:00
or-else 2a00791a1b simplified code a little bit 2022-06-29 12:20:43 -07:00
or-else dcf9e1d47e use topic.latestMsgVersion instead of iteration over versions 2022-06-29 10:15:57 -07:00
or-else 6c0612dc8f releasing v0.19.2 2022-06-28 14:37:15 -07:00
aforge 315631f67c Treat new webrtc:started messages as call invite.
In order to reduce redundancy, {info what=call event=invite}
will not be used as call invite any longer (and will be dropped soon).
2022-06-28 21:34:47 +03:00
or-else c31c0c2c07 fix clipping of a host name 2022-06-27 17:48:38 -07:00
aforge c7c2cffbbf Reject new incoming calls when a call is in progress. 2022-06-27 18:26:21 +03:00
or-else e77e8441cf recv ackns moved to the SDK 2022-06-26 19:29:51 -07:00
or-else d95c53ed24 add catch() to play(), otherwise it throws 2022-06-26 19:29:11 -07:00
or-else 300b4f8d64 typo 2022-06-23 16:23:22 -07:00
or-else 36977b429b small UI and metadata fixes 2022-06-23 13:23:06 -07:00
or-else 0d7643ee42 add video call screenshot 2022-06-19 16:43:43 -07:00
or-else 145ac78de8 bump version to 0.20.0-beta2 and rebuild 2022-06-19 12:55:34 -07:00
or-else 245603e8b9 dialing sound added 2022-06-19 12:53:09 -07:00
or-else 3eab7ec09b support for "ringing" 2022-06-19 11:52:07 -07:00
or-else a50261ab42 Merge branch 'master' into devel 2022-06-16 12:45:53 -07:00
or-else 8cd8ffb659 prevent crash when app is run under http, #763 2022-06-16 12:43:40 -07:00
Gene 932ab0a047 Ask to use index-dev.html instead of index.html 2022-06-16 19:44:00 +03:00
or-else b5686f45b3 bump version to 0.19.1 and rebuild 2022-06-14 09:32:46 -07:00
or-else 88c4cb42cd fix scroll bug in messages panel 2022-06-13 21:39:20 -07:00
or-else cd6a87037d bug fix: keep message panel at the bottom on new messages 2022-06-13 21:37:15 -07:00
or-else deaea078ea rebuilt 2022-06-13 15:32:32 -07:00
or-else bdfe163ecf clean up 2022-06-13 15:32:10 -07:00
or-else 1fb3a233a2 updated dependencies and rebuilt 2022-06-13 10:19:47 -07:00
or-else 0c91e4c615 merge devel 2022-06-13 09:04:52 -07:00
or-else b138fa2c44 mention french translation 2022-06-13 08:56:32 -07:00
or-else 70c4a775e3 rebuilt with french translation 2022-06-13 08:53:17 -07:00
Gene 978e858592 Merge pull request #99 from RomainStorai/frenchTranslation
Add French translation
2022-06-13 08:50:17 -07:00
Romain eca15317ce added fr to service worker 2022-06-13 16:49:50 +02:00
Romain a9961c2cc1 added french translation to i18n 2022-06-13 16:48:43 +02:00
or-else 557cbffe48 rebuilt 2022-06-12 16:11:25 -07:00
or-else 31258a75fa minor UI tweak 2022-06-10 19:28:07 -07:00
or-else f884874d43 incorporate "missing" and "declined" call statuses 2022-06-09 11:29:33 -07:00
or-else 762ccc1f31 rebuilt 2022-06-07 16:31:06 -07:00
or-else 10b3268d43 releasing 0.19.0 2022-06-05 13:41:22 -07:00
aforge fd1c13830c Stop ring sound after outgoing call has been accepted. 2022-06-04 21:49:01 +03:00
or-else 4e720a0d5b provide UI feedback for remote hangup 2022-06-03 16:50:06 -07:00
or-else 6dc91ad518 bump version to 0.19.0-rc2 2022-06-02 18:50:16 -07:00
or-else ae15a043b1 adding "end of call" sound 2022-06-02 18:46:33 -07:00
or-else 3af0b4409c bug fixes 2022-06-02 09:27:54 -07:00
or-else feb2e4b3e3 icon positioning fix 2022-06-01 11:33:51 -07:00
or-else 276d8c631a use server-provided ICE servers 2022-05-31 13:50:16 -07:00
or-else 720c9ae2d4 rebuilt with the sdk fix 2022-05-31 08:53:23 -07:00
or-else ed7e246f61 rebuilt with the sdk fix 2022-05-31 08:52:25 -07:00
or-else 0dea77b5f2 bump version to -beta1 2022-05-30 20:00:18 -07:00
or-else e287755f13 more idimatic VC formatting, better presentation of preview 2022-05-30 19:51:58 -07:00
or-else eef880c448 consistent naming or properties 2022-05-30 15:19:52 -07:00
or-else 1159efde80 bump version to v0.19.0-rc1 and rebuild 2022-05-30 10:26:57 -07:00
or-else 9507aeb28d screenshots updated with the audio message 2022-05-29 17:01:45 -07:00
or-else 95011e4a04 rebuilt 2022-05-29 16:42:44 -07:00
aforge fc94d7454f Merge pull request #97 from tinode/calls-head
Move video call message state and duration to head.
2022-05-29 23:54:49 +03:00
aforge 9dc1f6f3a7 Remove separate mime for video call messages. 2022-05-29 23:44:31 +03:00
aforge 3c87341280 Move video call message state and duration to head.
'webrtc' header for state.
'webrtc-duration' header for duration.

Content is now Drafty.videoCall() / VC.
2022-05-29 15:29:13 +03:00
or-else df460c6b61 a couple more UI tweaks 2022-05-28 18:50:11 -07:00
or-else fec6a5bfa6 call status in message preview was isolated in a class and made prettier 2022-05-28 18:36:54 -07:00
or-else 43e3395350 i18n of video calls 2022-05-28 16:54:43 -07:00
or-else 535d276665 call in progress animation, crash if button is clicked too soon 2022-05-28 14:42:06 -07:00
or-else b37ce5e8cf consistent capitalisation of menu items 2022-05-27 17:07:58 -07:00
or-else 147dfe0ea3 adding missing file 2022-05-27 17:05:52 -07:00
or-else 91eacd23ef nicer message bubbles for calls 2022-05-27 16:59:23 -07:00
or-else 97f3699e97 a small glitch 2022-05-27 15:17:00 -07:00
or-else 50c8c61078 made incoming calls panel prettier 2022-05-27 15:09:53 -07:00
or-else ff605369be correct method name 2022-05-27 11:16:37 -07:00
or-else 3d5b2bf7b3 add rining to outgoing call, convert souds to much smaller m4a 2022-05-27 11:13:07 -07:00
or-else 92aa11be39 outgoing call panel looks good 2022-05-27 10:40:38 -07:00
or-else 4e7c3419c0 make self-video mirrored 2022-05-26 21:47:17 -07:00
or-else feae1c9f46 package all sounds 2022-05-26 20:50:34 -07:00
or-else 293d71dea6 much smaller ring file, UI tweaks 2022-05-26 20:28:40 -07:00
or-else 7d2c7a634c a bit more work on sizing video 2022-05-26 19:44:58 -07:00
or-else d78dc0e22a making call UI prettier 2022-05-26 18:36:11 -07:00
or-else 203612b421 rebuilt with the sdk fix 2022-05-26 16:42:20 -07:00
or-else f40fd295b1 rebuilt 2022-05-26 14:40:04 -07:00
or-else b26239f1ff bump version to -beta2 2022-05-26 14:20:29 -07:00
or-else 13c1fcf811 merged with next 2022-05-26 10:16:09 -07:00
or-else a7d98136c5 interaction of push notification with tinode moved to the SDK 2022-05-25 14:22:56 -07:00
or-else 2a7c85c61c rebuilt with sdk changes 2022-05-25 10:56:26 -07:00
or-else 4ff315329c add a shim for "sub" push notifications 2022-05-24 20:21:49 -07:00
or-else 2b51f19fbd rebuilt 2022-05-23 20:49:50 -07:00
or-else a9677cee8b minor visual fixes for deleted topics 2022-05-23 15:47:34 -07:00
or-else 46b6a816eb more usable [scroll to bottom] button 2022-05-19 18:21:35 -07:00
or-else 6ffc03e8bd rebuilt 2022-05-18 14:32:18 -07:00
or-else ccfa7dacdf rebuilt 2022-05-18 13:59:38 -07:00
or-else ec01ac2a50 fixed pasting files and images 2022-05-17 15:53:11 -07:00
or-else a9e4f644a2 fix for breakage around forwarding review 2022-05-12 18:29:21 -07:00
or-else 5087a70d5e merge next 2022-05-12 10:12:50 -07:00
or-else 57466f61b3 updated version to -beta1, updated dependencies 2022-05-12 10:08:05 -07:00
or-else b4a5ad121b bump version to 0.18.6 and rebuild 2022-05-11 15:17:56 -07:00
or-else f9a563248b minor crash in audio and a couple of typos 2022-05-10 21:13:42 -07:00
or-else a4d765da14 merge email link fix 2022-05-10 12:40:48 -07:00
or-else de2f38bbd5 rebuilt with fix 2022-05-10 12:38:45 -07:00
or-else b45451893d fix "contact us" email link 2022-05-10 12:36:54 -07:00
or-else 2c4f005d76 better audio wave colors, move audio config values to config.js 2022-05-07 15:10:35 -07:00
or-else 30d57e11f2 better handling of missing audio preview 2022-05-05 15:50:19 -07:00
aforge d477fd4e82 Merge pull request #94 from tinode/calls-dev
Remove topic.videoCall('accept') error handling.
2022-05-02 11:24:16 -07:00
aforge 06945a01ea Remove topic.videoCall('accept') error handling. 2022-05-01 22:25:44 -07:00
or-else cc75d81538 rebuilt 2022-04-30 10:15:29 -07:00
aforge f50e79ea1e Merge pull request #93 from tinode/calls-dev
Video calls
2022-04-28 22:01:10 -07:00
aforge 706a7e0aaa Remaining code review items. 2022-04-28 22:00:11 -07:00
aforge ed62fe17c5 Some code review changes. 2022-04-27 23:08:19 -07:00
or-else e7290e6c62 bump version to 0.18.5 and rebuild 2022-04-26 09:25:37 -07:00
or-else e938c909a1 merge next 2022-04-25 14:02:35 -07:00
aforge 510a3d7113 Remove trailing spaces. 2022-04-24 22:13:25 -07:00
or-else c36fb2657a rebuilt with the sdk fix 2022-04-24 14:44:49 -07:00
aforge cc66356ad5 Formatting. 2022-04-23 21:29:47 -07:00
aforge c4d4aa77db Video calls: some more cleaup. 2022-04-22 20:43:10 -07:00
aforge 0daef53c87 More UI tweaks (calls).
1. Localize video call messages.
2. Display call message previews correctly.
2022-04-21 21:10:00 -07:00
aforge ad98bec73e UI tweaks.
1. Display call duration.
2. Make mute/unmute buttons work.
2022-04-20 22:33:14 -07:00
aforge 517f457182 Some formatting for video call messages. 2022-04-20 00:08:19 -07:00
aforge 0e92ace6f4 Ringtone for incoming calls. 2022-04-10 22:11:52 -07:00
aforge 3675812353 Merge branch 'devel' into calls-dev 2022-04-07 16:20:22 -07:00
aforge 4b9078e64a Calls logic minor cleanup. 2022-04-06 23:19:09 -07:00
or-else ca8235e1cc rebuilt with the sdk fix 2022-04-05 12:56:52 -07:00
or-else 56b9bb7814 updated dependencies and rebuilt 2022-04-04 17:14:27 -07:00
or-else 78116ef36f updated dependencies and rebuilt 2022-04-04 17:12:03 -07:00
or-else 408b221bac rebuilt 2022-04-04 10:48:01 -07:00
or-else 2a11885ac3 rebuilt with the sdk fix 2022-03-30 19:45:48 -07:00
or-else 62e4a4283f Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=642012 2022-03-27 11:31:54 -07:00
or-else 8fd49cc55e releasing 0.18.3 2022-03-24 14:05:40 -07:00
or-else 60b52b342d rebuilt with the sdk fix 2022-03-23 17:32:08 -07:00
or-else 570ef47455 rebuilt with the sdk fix 2022-03-23 17:14:01 -07:00
or-else ae2d7a12ad rename calcPreviewBars -> resampleBars 2022-03-23 16:10:47 -07:00
aforge b310a94180 Display video call labels. 2022-03-22 00:45:47 -07:00
aforge 45ad8bb2ad Merge branch 'devel' into calls-dev 2022-03-21 22:27:49 -07:00
or-else 2bc89f67f8 rebuilt 2022-03-19 14:53:51 -07:00
or-else 34ba874091 rebuilt with reporting fix 2022-03-19 14:53:27 -07:00
or-else 9ddd103ac5 correctly report topics 2022-03-19 12:56:32 -07:00
or-else 601c048ea5 fix broken spam reporting 2022-03-19 09:18:01 -07:00
or-else 237e97c323 don't immediately subscribe to a new p2p topic, wait for the first message' 2022-03-18 18:58:14 -07:00
or-else 891780bb52 stop recording on topic change 2022-03-17 15:52:25 -07:00
or-else c9391cf451 clear input field when changing topics 2022-03-17 14:12:25 -07:00
or-else 74b756d817 bump version and rebuild 2022-03-17 10:04:17 -07:00
or-else f3def8cf8a fix Drafty.preview signature 2022-03-17 09:48:55 -07:00
or-else 3d2af23f14 fix forwarding previews, remove audio button when forwarding 2022-03-16 20:01:00 -07:00
or-else 7044ce08c1 fix missing reply menu 2022-03-16 16:42:05 -07:00
or-else c5011a4383 fix missing reply menu 2022-03-16 16:34:46 -07:00
or-else d0c4251eda recording+playback+sending works correctly 2022-03-16 16:19:09 -07:00
aforge 2e0ed44763 Clean up video calls ui a bit. 2022-03-15 22:02:00 -07:00
or-else afea2aeec0 crash on missing preview 2022-03-15 19:27:54 -07:00
or-else e121b1645f putting it all together 2022-03-15 18:20:24 -07:00
or-else 2ab56887b7 Merge branch 'devel' into next 2022-03-15 09:14:21 -07:00
or-else 7e3a616983 fix for #91 (typo) 2022-03-15 09:04:14 -07:00
or-else e852e2972a visual cleanup of audio player 2022-03-15 09:03:14 -07:00
aforge ff7f8faf37 Video calls: consolidate onHangup and onClose. 2022-03-14 21:04:29 -07:00
or-else 589b46a8c2 audio messages are fully functional but buggy and ugly 2022-03-14 20:50:24 -07:00
aforge 824f15aa29 Hangup upon topic change. 2022-03-14 00:55:29 -07:00
or-else cc3b1190fd calc audio preview at the source 2022-03-13 15:36:30 -07:00
or-else 025d4cfadb adding audio playback 2022-03-12 18:41:10 -08:00
or-else d3cc0571ce fix date display with deleted messages 2022-03-12 10:53:55 -08:00
or-else 539a37e5ee Unsupported is correctly displayed 2022-03-11 18:39:52 -08:00
or-else e42a7ca6de corrected display of unsupported drafty entities 2022-03-11 16:43:19 -08:00
or-else 609d3e3a9f audio messages are correctly formed and sent 2022-03-11 15:42:07 -08:00
or-else 038f62d535 do not close audiocontext - throws 2022-03-10 18:00:55 -08:00
or-else 37451b9aff clean up audio after finishing recording 2022-03-10 17:42:39 -08:00
or-else 00a1b656e4 audio recorder mostly OK 2022-03-10 17:12:09 -08:00
or-else e0c412a33c audio recording widget is reasonably functional 2022-03-09 16:54:20 -08:00
or-else a2ca095c24 adding support for voice messages 2022-03-08 18:23:13 -08:00
or-else 42ae953055 minor cleanup, support for delayed topic creation 2022-03-07 19:41:17 -08:00
or-else 9c2d3f3693 clean up pushes 2022-03-05 20:09:02 -08:00
or-else 9d8a1fdfc4 removed debug logging 2022-03-04 13:58:11 -08:00
aforge 713cb4ea03 Merge branch 'devel' into calls-dev 2022-03-03 18:15:14 -08:00
or-else c6a9df9fe9 support for "read" pushes 2022-03-03 17:47:32 -08:00
or-else 470d38e583 merge devel 2022-03-01 18:11:20 -08:00
or-else 65dff7d168 bumped version to 0.18.2 and rebuilt 2022-03-01 12:15:42 -08:00
or-else d2d83a0865 rebuilt with updated sdk, version bumped 2022-02-28 16:04:35 -08:00
or-else aa498a7d1f add comment to update Firebase in service worker 2022-02-28 15:42:32 -08:00
or-else d106f6382a google's FCM API is VERY poorly designed 2022-02-24 17:39:51 -08:00
or-else cdff2ee697 a bit more work on delayed topic removal 2022-02-22 12:54:18 -08:00
or-else d76756c67a race condition when clearing DB 2022-02-21 13:20:46 -08:00
or-else 4edb48fd24 rebuilt with the SDK fixes 2022-02-20 18:20:27 -08:00
or-else 860ae42513 rebuilt with updated sdk 2022-02-20 15:17:29 -08:00
or-else 0598a42164 adding support for delayed removal of deleted topics 2022-02-19 20:58:35 -08:00
or-else 9a811708ba merge with master 2022-02-18 15:32:17 -08:00
or-else bb63cb204b rebuilt with fix 2022-02-18 10:12:01 -08:00
aforge f68057fe87 Merge branch 'devel' into calls-dev 2022-02-18 09:15:54 -08:00
or-else fb45c442a8 crash in message preview on empty topic 2022-02-17 16:07:59 -08:00
aforge acf4f0fa2a Merge branch 'devel' into calls-dev 2022-02-16 10:05:49 -08:00
aforge 6cea3e97db Make call establishment with pub/note/info work. 2022-02-15 18:24:33 -08:00
or-else e5545f5f14 bump version to 0.18.0 2022-02-15 17:20:37 -08:00
or-else 24d75dfbbc minor visual tweaks 2022-02-15 17:18:28 -08:00
aforge 5370b39fb6 Video calls 2022-02-13 19:31:06 -08:00
or-else 84a78ce6b5 added date badges (dividers) in messages view 2022-02-10 14:19:51 -08:00
or-else c8756c3a01 webpack updated to v10 2022-02-10 09:55:37 -08:00
or-else e3cb8a7291 merge conflicts resolved 2022-02-10 09:40:37 -08:00
or-else 3c11cbbdf1 adding vscode config to gitignore 2022-02-10 09:33:44 -08:00
or-else 9657a1e61e bump version to 18.0-rc3 2022-02-07 16:52:15 -08:00
or-else 728b3a3a1b crash on missing topic parameters, ranamed var for clarity 2022-01-28 10:17:14 -08:00
or-else 41bf6f3941 rebuilt with updated dependencies 2022-01-27 15:27:05 -08:00
or-else a02be831f2 minor fixes around remote avatars 2022-01-27 15:17:42 -08:00
or-else 5cfcd5789f removed unused imports, stripped src from IM before sending 2022-01-15 20:35:43 -08:00
or-else 28c34bcf7d rebuilt with the sdk fix 2022-01-12 19:15:38 -08:00
or-else e87a36421c fix crash on image with no data 2022-01-12 17:27:39 -08:00
or-else 479752036d rebuilt with the sdk fix 2021-12-29 17:38:43 -08:00
or-else c2d95725e4 fix missing context 2021-12-21 11:04:18 -08:00
or-else 9acca449e3 everything but images seems to be correct 2021-12-20 13:50:14 -08:00
or-else bd2def7882 formatting of forwarded messages previews 2021-12-20 12:27:02 -08:00
or-else b201f019ef reply formatting works correctly 2021-12-19 17:42:19 -08:00
or-else 1d59d29519 use new method for replies 2021-12-19 11:51:24 -08:00
or-else 9582a9f2cd simplify a bit and remove debug logging 2021-12-19 10:59:08 -08:00
or-else 363104755e completely remove quotes in previews 2021-12-19 10:51:16 -08:00
or-else ab7160170d refactoring split formatters - transformers 2021-12-19 10:05:16 -08:00
or-else afcb8d8616 some formatting fixes 2021-12-11 21:49:03 -08:00
or-else 69cf5817f0 rebuilt with the SDK fix 2021-12-05 20:18:25 -08:00
or-else 572fc66a69 sdk fix for attachment rendering in previews, bumped version 2021-12-02 12:00:09 -08:00
or-else 6a877620e6 fix for #90 2021-11-30 18:13:48 -08:00
or-else c7a60b9608 visual tweaks of forwarded replies 2021-11-25 14:57:08 -08:00
or-else 3feca32d13 strip mentioned name from the forwarded preview 2021-11-24 18:55:44 -08:00
or-else 081eb8723d bump version to 0.18.0-rc1 and rebuild 2021-11-21 13:29:08 -08:00
or-else a1034654a9 added [go to latest message] button 2021-11-21 11:06:21 -08:00
or-else faa3f60b55 adding [go to the latest message] button 2021-11-15 13:10:31 -08:00
or-else 31345eb298 rebuilt 2021-11-14 16:24:05 -08:00
or-else a9332495cf bump version to -beta2 and rebuild 2021-11-14 16:23:34 -08:00
or-else e4c5da3178 roll back to firebase 8, otherwise crash 2021-11-14 16:22:13 -08:00
or-else f3c6581fa5 rebuilt 2021-11-10 14:46:44 -08:00
or-else d6cf419266 emphesize that this is not a standaline app 2021-11-10 14:15:49 -08:00
or-else 939804eae0 move push setup to a separate file 2021-11-10 14:11:53 -08:00
or-else 2fc8826403 further shorten forwarded preview 2021-11-09 13:45:50 -08:00
or-else 298bca4042 replace screenshot with a more informative one 2021-11-09 10:58:30 -08:00
or-else f5d3b3af0b rebuilt with the sdk 2021-11-09 10:31:34 -08:00
or-else 733295f7e6 merge next into devel 2021-11-09 10:28:54 -08:00
or-else a706729f1b bump version to -beta1, update dependencies, add missing translation 2021-11-09 10:25:39 -08:00
or-else e180f526fb Use correct placeholder string in [Forward to] contacts search 2021-11-09 10:19:07 -08:00
or-else 990f929b4e shorten perview of a forwarded message 2021-11-09 09:58:25 -08:00
or-else 07292dffa4 dependencies updated 2021-11-09 09:57:43 -08:00
or-else 63b13f8cdd dependencies updated, version bumped to -beta1 2021-11-09 09:57:04 -08:00
or-else 70c15653a9 visual tweaks for mobile view and breakage in send message panel 2021-11-09 09:55:55 -08:00
or-else caf381496f visual tweaks for mobile view and breakage in send message panel 2021-11-09 09:55:22 -08:00
or-else 00b57cd4db screenshots updated 2021-11-07 16:46:38 -08:00
or-else 2c32c102d4 rebuilt with the sdk fix 2021-11-07 15:43:43 -08:00
or-else 57a6a33122 alignment tweak 2021-11-07 15:38:48 -08:00
or-else 73990fb907 correctly switch from forwarding to replying 2021-10-12 17:23:05 -07:00
or-else e3ae013875 fix failure to upload large avatar 2021-10-10 11:13:38 -07:00
or-else 0f031d2f0d added translation for Reply, Forward 2021-10-07 15:20:30 -07:00
or-else 9d61168cf2 bump asset sizes and rebuild 2021-10-07 11:42:59 -07:00
or-else 3feca6cb41 make mention font slightly lighter 2021-10-07 11:39:53 -07:00
or-else 825aa3fb51 cleanup and other minor UI fixes 2021-10-05 17:20:15 -07:00
or-else fb2b4b337b forwarding from channels and some other minor fixes 2021-10-05 16:42:10 -07:00
or-else 8f672405bc forwarding UI prettier and more usable 2021-10-05 15:56:20 -07:00
or-else a17b3d5b9c forwarding dialog looks and works as expected 2021-10-04 16:52:20 -07:00
or-else 2e45ac6bf9 UI tweaking for message forwarding 2021-10-04 15:44:22 -07:00
aforge ab5f2179ea Fix forward menu's height calculation. 2021-10-02 17:07:39 -07:00
aforge b8d1cd6f70 Merge pull request #88 from tinode/features
Message forwarding
2021-09-28 15:43:15 +03:00
aforge 4ad0baf99a Handling of forwarded messages in previews. 2021-09-28 15:40:42 +03:00
aforge 4f1c9e161a Merge branch 'next' into features 2021-09-28 11:25:18 +03:00
or-else a223d910c1 make LazyImage promise cancellable (otheriwse errors in the log) 2021-09-27 15:56:11 -07:00
or-else 2e589bef0c use fixed version of react-intl. 5.20.12 is broken 2021-09-27 15:54:46 -07:00
or-else 730426041a remove BR after the quoted block as it creates more problems than solves on Android 2021-09-27 12:11:03 -07:00
aforge 338e42dd2a Merge branch 'next' into features 2021-09-27 11:21:14 +03:00
or-else 35e3466a79 rebuilt 2021-09-26 15:22:45 -07:00
or-else a1e3cfb577 shorten image annotation 2021-09-26 15:19:43 -07:00
aforge 360a092584 Visual fixes in ForwardMenu. 2021-09-26 19:30:52 +03:00
or-else a9e356b927 make quote bottom padding bigger 2021-09-26 09:24:11 -07:00
aforge b6abc76f3f More code review suggestions. 2021-09-25 20:33:26 +03:00
aforge b1589a8790 Merge branch 'next' into features 2021-09-25 14:41:21 +03:00
or-else 03a2168761 rebuilt 2021-09-24 18:46:29 -07:00
aforge 3989d8cee9 Merge branch 'next' into features 2021-09-24 09:44:34 +03:00
or-else eae565edfa updated dependencies 2021-09-23 14:45:21 -07:00
or-else f9bf9b07d9 restore BR after the quoted text (but hide it using css) otherwise trouble on Android 2021-09-23 10:50:47 -07:00
aforge fb3fae8c9b Replace "Forwarded from" with an icon. 2021-09-23 20:07:21 +03:00
aforge 3a7a806420 Basic message forwarding. 2021-09-21 17:39:24 +03:00
aforge 03f076a1bc Merge branch 'next' into features 2021-09-20 16:56:40 +03:00
Gene 3a4d1f03c0 Merge pull request #87 from tinode/next-2
More refactoring
2021-09-19 20:44:04 +03:00
or-else 96c64b4b27 fix disappearing reply preview in image and doc previews 2021-09-19 10:41:00 -07:00
or-else 4175cffa4d everything seems to work as expected 2021-09-18 15:02:27 -07:00
aforge 9e8b55391a Merge branch 'next' into features 2021-09-18 16:26:54 +03:00
or-else c2c3b743b3 refactoring formatters for lazy display of scaled image previews (WIP) 2021-09-17 22:10:41 -07:00
or-else 8de413c5b5 refactored blob helpers to use promises instead of callbacks 2021-09-17 12:38:35 -07:00
aforge 4791f3dcc3 Merge branch 'next' into features 2021-09-17 09:18:58 +03:00
or-else 760289c6cf Merge branch 'next' of https://github.com/tinode/webapp into next 2021-09-16 18:06:20 -07:00
or-else ae528ef8d6 readme updated: replying is done, forwarding is not 2021-09-16 18:05:24 -07:00
Gene 3e5e9932d8 Merge pull request #86 from tinode/next-2
Mostly visual changes
2021-09-17 03:08:01 +03:00
or-else e72dc20192 everything works 2021-09-16 17:07:05 -07:00
or-else 707dfcf579 update formatjs extraction path 2021-09-16 11:58:49 -07:00
or-else 6343d3db81 fix lost API key for uploaded images 2021-09-16 11:32:03 -07:00
or-else 7117955126 add language attribute to <html> 2021-09-16 11:31:27 -07:00
aforge e98c7d5ed7 Merge branch 'next' into features 2021-09-16 16:20:26 +03:00
or-else 7bcfd974cb fix flashing of the bubble tip 2021-09-15 20:08:27 -07:00
or-else f51ba69fa7 everything seems to work now 2021-09-15 17:57:34 -07:00
or-else 228cc9669c code rearrangement 2021-09-15 13:45:50 -07:00
or-else 3a46599e79 code review changes, attempt at FF fix 2021-09-15 11:16:10 -07:00
or-else 84df30a767 rebuilt 2021-09-14 21:42:48 -07:00
or-else 58ae1359fa this transition is no longer needed 2021-09-14 21:23:33 -07:00
or-else 3d55ae1a89 visual tweaks + unneeded styles removed 2021-09-14 21:06:40 -07:00
or-else c00c1002e3 evrithing seems to work now 2021-09-14 20:36:57 -07:00
or-else 8ee83a9b26 file with formatters 2021-09-14 09:56:24 -07:00
or-else 68e1287fb8 image reply & invalid content fails, otherwise OK 2021-09-13 19:44:43 -07:00
or-else f57c5736e6 fixes 2021-09-12 12:23:14 -07:00
or-else 531a7ed74a a few more visual tweaks 2021-09-09 18:11:02 -07:00
or-else 4746b18ffd playing with colors and padding 2021-09-09 17:54:34 -07:00
or-else 067eba01fc visual update of replies 2021-09-09 16:57:31 -07:00
or-else 80b7a33e9f rebuilt 2021-09-09 11:36:27 -07:00
aforge 4a938b9aa8 Merge pull request #84 from tinode/next-reply
Reply feature draft vs "next"
2021-09-09 19:14:36 +03:00
aforge 55bfc5d2b6 Update umd and css. 2021-09-09 19:13:46 +03:00
aforge dd81190ff3 Code review suggestions.
* Reply to broken images.
* Remove 'viewportWidth' from SendMessage.
2021-09-06 11:26:19 +03:00
aforge 3e4ef8347f Rendering changes. 2021-09-05 21:15:06 +03:00
aforge b833844d09 Use Drafty.preview instead of Drafty.replyPreview. 2021-09-04 19:46:14 +03:00
aforge 2ace6b8ad7 Add a space after icon in IC formatting. 2021-09-01 17:50:14 +03:00
aforge 2fc1b97124 Bug fixes. 2021-09-01 17:45:59 +03:00
aforge 21d3115346 Code review suggestions. 2021-09-01 17:07:18 +03:00
aforge c408cc12c8 Merge branch 'next' into next-reply 2021-09-01 11:51:27 +03:00
or-else 0fd3fc2e23 suppress link on MN in preview, check for invalid content in preview 2021-08-31 21:23:56 -07:00
or-else ffca6573df fix crash on unknown fmt in drafty message (QQ in this case) 2021-08-31 19:44:53 -07:00
aforge 9e7c12f9e5 Minor style change. 2021-08-31 17:23:15 +03:00
aforge df39bccf78 More bug fixes. 2021-08-31 17:08:22 +03:00
aforge 186d9006fc Merge branch 'next' into next-reply 2021-08-31 15:44:35 +03:00
or-else 25bd8d2ada make some checkboxes for long-running state changes 3-state 2021-08-30 16:44:44 -07:00
aforge 2edfaeecde Make handleQuoteClick work. 2021-08-30 23:35:13 +03:00
or-else 331c17a7cc merge next into next-reply 2021-08-29 11:10:13 -07:00
aforge 53936f11e0 Updates to replyTo feature. 2021-08-29 00:40:23 +03:00
aforge 9660b9016b Merge branch 'next-reply' of https://github.com/tinode/webapp into next-reply 2021-08-28 23:45:23 +03:00
or-else 87c73869af releasing v0.17.10 2021-08-26 18:19:52 -07:00
or-else d706cc4f6b a couple of minor changes for clarity 2021-08-26 18:04:23 -07:00
or-else a45843fd39 a bit of refactoring to allow delayed new p2p subscriptions (not done yet) 2021-08-25 14:23:41 -07:00
aforge 656cfb3932 Merge branch 'devel' into features 2021-08-24 09:47:06 +03:00
or-else 0f186b6b3b a bunch of small bugs fixed 2021-08-23 21:10:04 -07:00
or-else efc0dfddc0 fix message scrolling on initial load 2021-08-23 17:16:59 -07:00
or-else 667d0e6d99 fix a race condition in handleScrollEvent (#85) 2021-08-23 17:14:21 -07:00
or-else 763a525292 fix message scrolling on initial load 2021-08-23 16:54:52 -07:00
or-else f3e54e46b5 fix a race condition in handleScrollEvent (#85) 2021-08-23 15:30:08 -07:00
aforge 292e974d29 Merge branch 'devel' into features 2021-08-23 10:41:19 +03:00
or-else 8294248e2d stop typing indicator on message received 2021-08-22 19:40:08 -07:00
or-else e938b4ecd5 rebuilt 2021-08-22 19:11:11 -07:00
or-else bf19cd1351 minor visual fixes 2021-08-22 19:09:29 -07:00
or-else ae197dac8f translation for Archived: 2021-08-22 17:37:46 -07:00
or-else 5ea4fdf05e updated translations: zh, zh-TW 2021-08-22 15:21:33 -07:00
or-else 33eed09c84 translations updated: ko, ro 2021-08-22 15:00:06 -07:00
or-else dd79587a63 updated translations: de, es, ru 2021-08-22 14:39:24 -07:00
or-else e73a920154 rebuilt with translations 2021-08-22 12:23:29 -07:00
or-else 9c4db41b76 fixing unarchive menu, translations 2021-08-22 12:22:47 -07:00
or-else 7ba20c78b1 adding unarchive menu item 2021-08-22 11:45:50 -07:00
or-else a6c29e3e5d rebuilt 2021-08-22 11:01:16 -07:00
or-else 775915e6b2 merging devel 2021-08-22 10:54:32 -07:00
or-else 1d0c7e1c73 fixing "archive" menu item 2021-08-22 10:52:21 -07:00
aforge 9c0bdb4848 Merge branch 'devel' into features 2021-08-22 14:43:24 +03:00
or-else 13432292cc fix vertical text alignment in info-box 2021-08-21 20:24:14 -07:00
or-else 62d289a270 another little fix 2021-08-20 21:05:24 -07:00
or-else b893f28145 a big more of manual merging 2021-08-20 21:02:39 -07:00
or-else ea816859b6 manual merge of devel changes 2021-08-20 20:55:51 -07:00
or-else 9508797ab9 added a "password reset email has been sent" screen 2021-08-20 20:50:08 -07:00
or-else 36aa610b8e tweaking color 2021-08-20 20:39:24 -07:00
or-else 8822c2892e added a "password reset email has been sent" screen 2021-08-20 20:32:56 -07:00
aforge 9626995b1c Merge branch 'devel' into features 2021-08-20 09:25:15 +03:00
or-else ddfbfdcbb3 releasing 0.17.9 2021-08-19 18:56:36 -07:00
or-else b2a02fb78c merge remote 2021-08-19 16:23:31 -07:00
or-else 29e3bd4f9f merge next 2021-08-19 16:22:33 -07:00
or-else c522b90150 a bunch of small fixes in refactored UI 2021-08-19 16:21:16 -07:00
aforge 454b594b96 Merge branch 'devel' into features 2021-08-19 11:44:53 +03:00
or-else 2cc2258682 fixed bugs in tags editing 2021-08-18 19:51:19 -07:00
or-else 529486feb3 colorize user names under messages 2021-08-18 16:00:47 -07:00
or-else faf644d9dd bump version to v0.17.9-rc2 2021-08-17 10:44:38 -07:00
or-else fb3de109e0 merge devel 2021-08-16 16:06:32 -07:00
or-else c34ba46563 rebuilt 2021-08-16 16:05:27 -07:00
or-else b783ec2949 remove loading... spinner on database fetch 2021-08-16 16:04:32 -07:00
or-else 982b40d6e8 mereg devel 2021-08-16 11:52:41 -07:00
or-else 0840a79024 use topic.messages directly instead of creating a copy in this.state 2021-08-16 11:40:30 -07:00
or-else 93548d644f rebuilt 2021-08-15 15:27:27 -07:00
Gene ce07c81bf7 Merge branch 'next' into next-reply 2021-08-15 11:13:44 -07:00
or-else 34afdcac1a rebuilt 2021-08-15 10:54:51 -07:00
aforge 8af1727c3d Merge branch 'devel' into features 2021-08-15 09:47:38 +03:00
aforge 08c295b704 Merge branch 'next' into next-reply 2021-08-15 09:25:32 +03:00
or-else 7eb3ca32bb clear indexeddb on filed token login 2021-08-14 17:08:31 -07:00
or-else d22b35aacc a couple more visual and logical fixes 2021-08-14 16:24:45 -07:00
aforge e065d71aed Merge branch 'next' into next-reply 2021-08-14 16:31:09 +03:00
aforge b482f6649d Basic and very raw implementation of the Reply To feature. 2021-08-14 16:30:18 +03:00
or-else d6ca3f9f3c fix editing permissions 2021-08-13 20:35:39 -07:00
or-else d741f6ec92 merge devel 2021-08-13 17:29:42 -07:00
or-else 4399428fdf rebuilt with translations 2021-08-13 15:08:42 -07:00
or-else e0e25fc6d3 updated dependencies 2021-08-13 15:01:42 -07:00
or-else 25e9c1a885 added missing translations 2021-08-13 15:01:17 -07:00
or-else b59fd06381 fix some visual breakage with long strings in messages 2021-08-13 15:00:38 -07:00
or-else b540ac44df make owner delete chat instead of leaving 2021-08-13 14:59:49 -07:00
aforge 1e08211d17 Merge branch 'devel' into features 2021-08-13 09:49:39 +03:00
or-else 6c739d9c43 bump version and rebuild 2021-08-11 11:53:49 -07:00
or-else 9a2e0519d3 fix deleting the photo 2021-08-10 18:13:21 -07:00
or-else 47e7632399 better caching of out-of-band images 2021-08-09 16:04:31 -07:00
or-else 58e319b58e kind of works, no crashes, but inefficient and not everything is refreshed 2021-08-09 14:03:33 -07:00
or-else 17a658e373 a couple minor tweaks 2021-08-08 18:59:34 -07:00
or-else 1c0b464bed more or less complete 2021-08-08 18:51:25 -07:00
or-else fcb61dcc3b a ton more refactoring 2021-08-07 15:34:22 -07:00
or-else 6d4ff9d625 a lot of refactoring of topic-info 2021-08-06 14:58:05 -07:00
or-else 642821db9f refactoring for better topic and account editing 2021-08-05 16:32:13 -07:00
or-else dafc3161c5 fixing alignment issues with long user and topic names 2021-08-04 21:46:59 -07:00
aforge 34712ba1f0 Merge branch 'devel' into features 2021-08-04 09:47:58 +03:00
or-else 47304c7baf merge master 2021-08-03 20:19:35 -07:00
or-else 37e8912fa5 merge devel 2021-08-03 20:15:53 -07:00
or-else 274da95b71 ignore 2021-08-03 20:09:40 -07:00
or-else 59138c9f5f fix i18n string extraction 2021-08-03 18:52:42 -07:00
or-else b555595f64 rebuilt 2021-08-03 16:36:22 -07:00
or-else b2daf6b58e bump version and rebuild 2021-08-03 11:18:48 -07:00
or-else e636c4a30b rebuilt 2021-08-03 11:14:21 -07:00
aforge 4005b01a85 Merge branch 'devel' into features 2021-08-03 16:27:10 +03:00
or-else 063245e665 refreshed Translations section 2021-07-31 15:02:42 -07:00
or-else e3bdd5bb45 zh-TW translations in service worker 2021-07-30 09:40:10 -07:00
or-else 122fd018d0 fixing #80 in service-worker 2021-07-30 09:37:25 -07:00
or-else febc75da17 ensure sort order of contacts by [touched] 2021-07-29 19:16:38 -07:00
or-else eff8bbabc5 bump version and rebuild 2021-07-29 17:12:09 -07:00
or-else c7f4fa0165 rebuilt with chinese traditional translation 2021-07-29 17:11:52 -07:00
or-else a0312f8b52 fix leaving a conversation 2021-07-29 17:11:08 -07:00
or-else 43be8d6662 reguilt with the next translation 2021-07-29 14:18:47 -07:00
Gene 8570677388 Merge pull request #82 from SiderealArt/patch-1
Update zh-TW.json
2021-07-27 17:36:04 -07:00
SiderealArt 081548b35a Update zh-TW.json 2021-07-28 08:05:54 +08:00
or-else fd14638c83 update format of zh-TW translation file 2021-07-27 11:29:34 -07:00
or-else 87fa1ee107 Merge branch 'devel' into next 2021-07-27 10:11:44 -07:00
or-else f8879f7b68 rename the new translation according to ISO 639-1 2021-07-27 10:11:25 -07:00
Gene 39c8b9a4a1 Merge pull request #81 from SiderealArt/patch-1
Add Traditional Chinese translation
2021-07-27 09:45:29 -07:00
SiderealArt 812cdd970c Add Traditional Chinese translation 2021-07-27 19:22:34 +08:00
or-else 3da0984ae0 clarifying a couple of comments 2021-07-22 15:33:33 -07:00
aforge 1961e76699 Basic menu item for message replies. 2021-07-20 18:19:34 +03:00
or-else 62fc84912e refactored translations to avoid breakage 2021-07-18 21:54:39 -07:00
or-else eb299b73d2 converted translations to a new format to avoid data loss on failed builds 2021-07-18 21:31:13 -07:00
or-else b35ff16faa rebuilt 2021-07-18 19:27:11 -07:00
or-else 094173fe02 refactoring info-view into reusable components 2021-07-18 19:26:34 -07:00
or-else fdfede5069 merge devel 2021-07-17 11:10:34 -07:00
or-else a24ee01b37 remove crash in Safari, #80 2021-07-17 10:28:35 -07:00
or-else 92f1d53e48 correct use of reference 2021-07-16 14:28:13 -07:00
or-else f81aaea837 change method signatures to make TopicCommonView usable in INfoView 2021-07-16 14:19:56 -07:00
or-else f1ba810765 remove deprecated findDOMNode 2021-07-16 14:18:38 -07:00
or-else b704499308 making AccGeneralView more generic, using msg.extra for attachments 2021-07-16 12:57:30 -07:00
or-else 0cd432a52f minor cleanup 2021-07-15 18:51:56 -07:00
or-else 0afef6c587 fixing display of avatars by URL 2021-07-15 17:29:06 -07:00
or-else 4219e47529 making uploads work 2021-07-14 16:06:37 -07:00
or-else 8320bf28ce looks like cropping works correctly 2021-07-13 16:27:51 -07:00
or-else ba5b82115f fixxing some build breakage 2021-07-13 09:49:20 -07:00
or-else bb9d47c7e0 adding new view name 2021-07-13 09:36:40 -07:00
or-else f1196e17ff wip: cropping mostly works, uploads do not yet 2021-07-13 09:35:17 -07:00
or-else ab962bfe7d wip: image cropping 2021-07-13 09:33:49 -07:00
or-else 07854ba9fc wip on avatar cropping 2021-07-10 19:22:56 -07:00
or-else f2cd1ba0f2 making avatar cropping work 2021-07-10 19:11:21 -07:00
or-else 6b8595750a merge devel 2021-07-10 17:26:20 -07:00
or-else aa451946a5 releasing 0.17.6 2021-07-10 17:25:00 -07:00
or-else 0195aae6ff wip: hooking up avatar cropper 2021-07-10 17:17:48 -07:00
or-else 6b8c3949a0 add src/views/avatar-crop-view.jsx 2021-07-10 15:39:56 -07:00
or-else bc6e2ab8d2 wip: avatar panning and zooming 2021-07-10 15:39:02 -07:00
or-else 72f4f47e8f adding zoom/crop widget for avatar uploads 2021-07-10 12:40:18 -07:00
or-else ecb5729227 rebuilt with changes 2021-07-07 16:50:22 -07:00
or-else fd8e2d9a17 work in progress on avatar uploading 2021-07-07 16:49:44 -07:00
or-else 4975084eef merge conflic resolved 2021-07-07 16:39:03 -07:00
or-else d1ea1d7658 add React.StrictMode checking for devel 2021-07-07 16:35:39 -07:00
or-else 626484d8cb releasing 0.17.6-rc1 2021-07-07 15:24:12 -07:00
or-else 864753942f rebuilt with indexedDB initialization fix 2021-07-06 09:33:21 -07:00
or-else 66c2db86d9 rebuilt with the fix & bumped versionfor new release 2021-07-02 10:47:05 -07:00
or-else e30868c072 wip: out of band avatars 2021-07-01 14:19:28 -07:00
or-else c5c08aa4f9 minor visual fixes 2021-06-28 10:45:16 -07:00
or-else bc67378bc9 adding missing korean translation 2021-06-28 09:44:56 -07:00
or-else 649d6e0494 trusted badges work as expected 2021-06-27 18:20:45 -07:00
or-else 1a8f6351ef rebuilt 2021-06-26 08:59:12 -07:00
or-else 58426cd0ee adding support for trusted, verified/staff/danger badges, wip 2021-06-25 12:27:03 -07:00
or-else 3d0d7048aa start 0.18.0 development 2021-06-25 11:17:18 -07:00
or-else 9033c1ade0 add menu item for restoring topics from archive 2021-06-25 11:17:01 -07:00
or-else bf9ba90ed3 clear reconnect timer on logout 2021-06-24 19:54:58 -07:00
or-else 07aa38e162 rebuilt with sdk fixes 2021-06-23 17:32:38 -07:00
or-else 181161250a rebuilt with the sdk fixes 2021-06-23 15:57:24 -07:00
or-else 019b270242 wrong order of params in file uploads 2021-06-23 14:03:41 -07:00
or-else cf5fd9753c release version 0.17.2 and rebuild 2021-06-21 14:47:53 -07:00
or-else a465fcf935 update dependencies, bump version and rebuild 2021-06-20 10:47:27 -07:00
or-else 2d01e0af85 fixed topic subscription on page reload 2021-06-18 19:13:59 -07:00
or-else 3fcd118af8 bumped version and rebuilt with fixes 2021-06-18 17:58:56 -07:00
or-else cdf27b4d48 fixed layout of tabs in new topic view 2021-06-18 17:58:32 -07:00
or-else ab993e36b9 bumped version, updated dependencies, rebuilt 2021-06-15 14:53:08 -07:00
or-else 9277db4dcd rebuild 2021-06-10 19:55:42 -07:00
or-else 9b76577bb9 fix occasional breakage in makeImageDataUrl 2021-06-10 19:54:42 -07:00
or-else c57fd6e539 fix DB initialization and teardown, wrong upload parameters order 2021-06-09 15:58:07 -07:00
or-else 2ffe42741d merge devel 2021-06-04 18:23:41 -07:00
or-else 5d97746d9d remove support for obscure browsers 2021-06-04 18:21:51 -07:00
or-else e22a84c8ad Merge branch 'master' of https://github.com/tinode/webapp 2021-06-03 18:20:58 -07:00
or-else 2b12baa166 updated dependencies, bumped version to 0.17.1 and rebuilt 2021-06-03 18:20:12 -07:00
or-else 2ecb522b44 updated dependencies including react-intl (previously broken) 2021-06-02 17:43:43 -07:00
Gene 6527ae8613 Update feature_request.md 2021-06-02 00:26:30 +03:00
Gene 10aa29e68b Update issue templates 2021-06-02 00:25:59 +03:00
or-else d944ddccd5 bump version to 0.17.1-rc and rebuild 2021-05-30 11:33:33 -07:00
or-else ef469ce419 rebuilt 2021-05-19 15:19:06 -07:00
or-else c1b3040f8a ad references to native apps to manifest 2021-05-19 15:13:08 -07:00
or-else bf0c818c28 pass topic name in form response 2021-05-19 15:13:05 -07:00
or-else 92f7464477 ignore trailing deleted ranges for message previews 2021-05-19 15:11:06 -07:00
or-else 034944877c fix cache issue on logout 2021-05-19 13:44:24 -07:00
or-else 694b93ebe8 merge devel 2021-05-10 17:00:00 -07:00
or-else 29e5be4e01 pass topic name in form response 2021-05-10 16:50:24 -07:00
or-else 02e23c885a tabs to spaces, typo in comment 2021-05-10 11:40:58 -07:00
or-else 74d5473a8b adding support for out of band avatars 2021-05-10 11:38:53 -07:00
or-else 02e1a28e89 rename vcard to theCard 2021-05-09 14:12:46 -07:00
or-else 2c61ab26c8 prepare for foreground notifications 2021-05-09 14:07:41 -07:00
or-else eeb14cf752 better cache handling 2021-05-09 14:07:05 -07:00
or-else e97e708c01 adding another generated file 2021-05-08 18:01:47 -07:00
or-else aa8d60cc1d a bit of cleanup, better naming of the app cache 2021-05-08 18:01:14 -07:00
or-else 62259ab331 releasing 0.17.0 2021-05-06 11:04:52 -07:00
or-else c010d1d17a bump version and rebuild 2021-05-03 16:39:46 -07:00
or-else 91e3f23d4e rebuilt with sdk fixes 2021-05-03 16:20:37 -07:00
or-else 1d6b970d7b removed "undefined" color 2021-05-02 16:48:53 -07:00
or-else 430b2db9aa fix bubble being too wide on mobile 2021-05-02 09:48:07 -07:00
or-else 4a480d71cc fix the issue with loading topics from cache 2021-05-01 20:07:24 -07:00
or-else 1537016942 fix incorrect initialization of promise when initializing pushes 2021-05-01 08:14:54 -07:00
or-else 1023e80fa6 another really monor visual tweak for large images 2021-04-30 11:45:34 -07:00
or-else 91e1959833 bump version to -rc2 and rebuild 2021-04-29 13:44:06 -07:00
or-else 0add66f607 a few more visual tweaks 2021-04-29 10:41:03 -07:00
or-else 273e34de2c clear reconnect timer on new connection 2021-04-28 17:05:00 -07:00
or-else 7adc06332e rebuilt 2021-04-28 16:33:24 -07:00
or-else 271fbc4b02 message timestamp is always shown on the bottom right now 2021-04-28 16:32:36 -07:00
or-else f6acbaab21 i18n of drafty preview elements 2021-04-28 13:34:14 -07:00
or-else 8715251e9b explain the workaround 2021-04-26 20:21:35 -07:00
or-else 8c5660173c workaround for what looks like a chrome bug 2021-04-26 19:41:58 -07:00
or-else 52ec124cd6 hopefully last fix 2021-04-25 16:53:40 -07:00
or-else cf423ff5eb formatting fixes 2021-04-25 16:52:30 -07:00
or-else bd24711785 refreshed the screenshots 2021-04-25 16:48:52 -07:00
or-else 304b5fac6e bug fix: don't cache POST responses 2021-04-25 16:48:00 -07:00
or-else 9cfbfe48ea preserve navigation state on page reload 2021-04-23 17:58:33 -07:00
or-else 9e32098b55 later builds of react-intl do not support umd 2021-04-21 17:13:56 -07:00
or-else 2a1814254c dependencies updated 2021-04-20 17:40:28 -07:00
or-else e0633bfa0d bump version and rebuild 2021-04-20 15:41:47 -07:00
or-else a33891a5a9 refresh channels on push, correctly set push enabled status 2021-04-12 16:59:01 -07:00
or-else 8504e4a155 fix FCM initialization and a crash 2021-04-09 16:31:33 -07:00
or-else 390a380763 bump version and rebuild 2021-04-08 15:12:07 -07:00
or-else c01f80c248 more generic names of some css classes 2021-04-06 17:34:10 -07:00
or-else 7427c45c81 rebuilt 2021-04-04 18:37:06 -07:00
or-else 5175ac1dfe make some css class names more generic 2021-04-04 18:35:41 -07:00
or-else 9e17dc73fe rebuilt with linked sdk 2021-04-04 17:33:06 -07:00
or-else 8843e562da possible fix for #67 2021-04-04 11:46:26 -07:00
or-else 5146a6c3d8 rebuilt 2021-03-30 19:40:34 -07:00
or-else c22ee282e9 fix: remove Not Found from author field in deleted group messages 2021-03-29 19:05:19 -07:00
or-else 0b5ac811c4 version bumped, rebuilt 2021-03-25 16:09:48 -07:00
or-else 096399fb0a rebuilt with fixes 2021-03-15 16:48:47 -07:00
or-else e703490a1e add 'chn' to the list of permitted topic name prefixes 2021-03-15 16:47:31 -07:00
or-else 6cd180771a fix missing topic name in contacts 2021-03-15 16:47:03 -07:00
or-else beb861d707 rename ambiguous variable 2021-03-15 16:46:39 -07:00
or-else d4f299aa40 fix broken confirmation menus 2021-03-14 14:04:46 -07:00
or-else c2b1433ed6 cosmetic fixes 2021-03-13 16:56:19 -08:00
or-else 6157557a12 Merge branch 'devel' of https://github.com/tinode/webapp into devel 2021-02-28 15:27:19 -08:00
or-else 0443814f0c add required react keys to extra elements 2021-02-28 15:27:02 -08:00
or-else a5537a5664 fix display of form previews 2021-02-28 14:48:54 -08:00
or-else ebb7537b2f dependencies updated 2021-02-28 14:47:33 -08:00
or-else efd1461102 visual tweaks of message preview and buttons 2021-02-28 14:46:29 -08:00
Gene 7022cf1f64 Merge pull request #64 from kant/patch-3
Typos fixed (lines 51 & 108)
2021-02-22 18:32:21 -08:00
Darío Hereñú 6689e68a7a Typos fixed (lines 51 & 108) 2021-02-22 21:19:58 -03:00
or-else 99423dd04f clear device token when requested 2021-02-21 16:48:27 -08:00
or-else 50344f3cd5 adding placeholder for foreground topic fcm messages 2021-02-17 18:37:06 -08:00
or-else b410879ca9 google renamed a method, fixing 2021-02-17 18:36:17 -08:00
or-else d904af2d2c rebuild with sdk bug fixes 2021-02-13 08:54:49 -08:00
or-else d28d498e03 change version to -alpha1 2021-02-11 14:19:50 -08:00
or-else c81cfa3af3 fix for #62 2021-02-05 11:12:06 -08:00
or-else 705932825a bump version to beta-1 2021-02-04 15:29:22 -08:00
or-else 6ace7e72ab message previews work 2021-02-03 19:04:25 -08:00
or-else 4d972791af build with correct version of the sdk 2021-01-29 09:42:03 -08:00
or-else 3187625dc5 merging devel fixes 2021-01-29 09:38:26 -08:00
or-else fb5447c92f fix misnamed message ids 2021-01-29 09:20:22 -08:00
or-else 55c24f884e Merge branch 'devel' into next 2021-01-28 17:18:40 -08:00
or-else 5795e40573 rename flatten-messages.js -> pack-messages.js 2021-01-28 16:58:11 -08:00
or-else f7652ec3c3 don't minimize dev output 2021-01-28 16:57:39 -08:00
or-else 60b4ed034a rebuilt messages 2021-01-28 16:53:11 -08:00
or-else 189634657e updated scripts and added locale section 2021-01-28 16:52:21 -08:00
or-else dfcacdc392 updated scripts and added locale section 2021-01-28 16:52:03 -08:00
or-else 25a22d7da9 mention new translation scheme 2021-01-28 16:51:11 -08:00
or-else 9e574aa6c5 check in file with raw extracted strings 2021-01-28 16:28:19 -08:00
or-else 3144e7a721 a few more format changes 2021-01-28 16:23:17 -08:00
or-else bc3f13d360 fix message format which causes failure to extract 2021-01-28 16:02:29 -08:00
or-else 010665284b firmatjs requires exlicit declarations everywhere now 2021-01-27 18:21:06 -08:00
or-else 6f47cc50b0 resolving merge conflict 2021-01-27 09:42:25 -08:00
or-else 1172adefb4 fix formatjs setup, #62 2021-01-26 13:58:59 -08:00
or-else 4f38a2d8b1 fixing formatjs build 2021-01-25 17:29:43 -08:00
or-else d75448c548 rebuilt with sdk fixes 2021-01-24 19:03:51 -08:00
or-else 9c69b7129c tinode setup is async now 2021-01-24 17:41:41 -08:00
or-else 4ea82f85ba rebuilt with refactored sdk 2021-01-23 19:08:24 -08:00
or-else 3132294cce persistent cached hooked up on cold start; warm start still crashes 2021-01-22 16:55:01 -08:00
or-else 6f278f5bb5 indexeddb cache kind of works but buggy and incomplete 2021-01-21 20:17:24 -08:00
or-else 92e3f15c01 adding indexedDB persistent cache 2021-01-20 17:45:01 -08:00
or-else 1f5a76e481 received marker in message previews 2021-01-19 18:50:18 -08:00
or-else 4445a90437 fix UI jerk on updating message preview 2021-01-17 18:08:16 -08:00
or-else 0afa8bcdcd more work on supporting message previews 2021-01-17 17:26:57 -08:00
or-else 55d7e0d5cb don't show time in dates long in the past 2021-01-14 13:59:20 -08:00
or-else 4b07c3b5c9 drafty previews 2021-01-13 15:17:56 -08:00
or-else a319e2b0bc CDN changed react-int paths 2021-01-11 19:39:24 -08:00
or-else 924459f84b update build due to changes in webpack and react-intl 2021-01-11 16:21:26 -08:00
or-else 396b23ee88 dependencies updated 2020-12-27 20:55:46 -08:00
or-else 775bc954e3 bump version and rebuild 2020-12-27 10:38:53 -08:00
or-else 6fabb6c111 fix missing file in npm package, remove screenshots from package 2020-12-20 13:11:48 -08:00
or-else e328e6a4c2 dependencies updated 2020-12-20 13:01:55 -08:00
or-else 443245280f rebuild with sdk changes 2020-12-20 12:14:42 -08:00
or-else a248151243 rebuilt with a fix in the sdk 2020-12-18 12:11:07 -08:00
or-else e05e75ed28 use server-provided limits instead of hardcoded ones 2020-12-17 20:14:03 -08:00
or-else 4d450e6b85 show indicators for channels 2020-12-16 16:27:23 -08:00
or-else b014592381 rebuilt with the bug fix in tinode-js 2020-12-13 19:03:16 -08:00
or-else 1d1c9400f8 dependencies updated 2020-12-11 17:19:01 -08:00
or-else 97d4bcfba9 update version, add romanian translation to service worker 2020-12-10 11:07:04 -08:00
or-else ed19ed750c change style of divider in chat list 2020-12-06 20:18:28 -08:00
or-else 93e7b69f88 Merge branch 'devel' of https://github.com/tinode/webapp into devel 2020-12-06 19:47:09 -08:00
or-else af58db914b fixed id of a translated message 2020-12-06 19:47:01 -08:00
or-else 0c9e6379d6 mention Romanian translation, fix reference to error message 2020-12-04 12:00:09 -08:00
Gene 311b36b977 Merge pull request #60 from georgelemon/ro-language-support
Adding i18n support for romanian language
2020-11-24 13:37:14 -08:00
George Lemon d12fd0e16f Adding i18n support for romanian language 2020-11-24 22:45:26 +02:00
or-else a7998a1d29 better visual style of broken images 2020-11-21 17:21:56 -08:00
or-else 231635f86d increase max inline image size 2020-11-10 14:47:58 -08:00
or-else 5574085a01 rebuilt 2020-11-02 15:08:31 -08:00
or-else 5d53899f89 fix for https://github.com/tinode/chat/issues/563 2020-11-02 11:38:41 -08:00
or-else 97c8114fb7 uploading and downloading works 2020-10-25 16:19:56 -07:00
or-else 28d825c681 out of band image uploads work, need testing and polish 2020-10-24 20:49:43 -07:00
or-else cede800726 wip: handling previews/cancellations while uploading 2020-10-22 17:40:40 -07:00
or-else 53c1f9fd0b everyting works except upload progress 2020-10-17 19:05:31 -07:00
or-else 796f16a003 works except for upload progress 2020-10-16 17:43:12 -07:00
or-else 738d84045d wip: fixing bugs in image uploads and display 2020-10-15 17:36:35 -07:00
or-else de1028e141 wip: out of band inline images are starting to work 2020-10-14 16:55:54 -07:00
or-else f9d622a8d8 wip: image scaling by size 2020-10-13 16:30:34 -07:00
or-else fbdf1df9d0 wip: adding out of band image sending 2020-10-13 14:46:45 -07:00
or-else 09baee8d73 Merge branch 'devel' 2020-10-11 10:23:33 -07:00
or-else 84d0ec5e9c bump version 2020-10-11 10:23:06 -07:00
or-else 4d9bc435fc Merge branch 'master' of https://github.com/tinode/webapp 2020-09-30 11:40:16 -07:00
Gene 7d08e65e64 Merge pull request #59 from HitmanCodes/master
Update README.md
2020-09-30 08:12:52 -07:00
HitmanCodes 6e19bf6add Update README.md 2020-09-30 19:16:46 +05:30
or-else 96ad18a902 dependencies updated 2020-09-15 13:57:35 -07:00
or-else 58983588a4 show "channel" instead of "online" in message view caption 2020-09-14 15:54:14 -07:00
or-else 9401a8d483 topic redirects work now, bug with starting topic from search results fixed 2020-09-04 11:01:44 -07:00
or-else 6019303f2c fixes for bugs in starting new topics 2020-09-03 15:03:51 -07:00
or-else 28fd6a7236 fix channel permissions & disable deleting messages 2020-08-25 17:44:14 -07:00
or-else e8d48eb5f0 adding missing dependency 2020-08-25 13:50:48 -07:00
or-else d91534236f dependencies updated 2020-08-24 11:38:01 -07:00
or-else 4d3682c0ef rebuilt 2020-08-01 15:12:20 -07:00
or-else 45c6cf22ff support for channels 2020-08-01 15:10:37 -07:00
or-else c56b30337f rebuilt 2020-07-31 15:40:07 -07:00
or-else ab128ba6bb dependencies updated, security fix for elliptic pkg 2020-07-30 13:51:40 -07:00
or-else b2bbe75f6d bump version. channel UI works 2020-07-20 14:12:20 -07:00
or-else 0f095ceeca adding channel support 2020-07-20 12:20:38 -07:00
or-else 331dea37cf bump version and rebuild 2020-07-17 10:02:40 -07:00
or-else af72df8a4f dependencies updated 2020-07-17 09:22:26 -07:00
or-else 4f56dfc276 hide load spinner on failed login 2020-07-13 18:20:38 -07:00
or-else 2817d83c56 bump version and rebuild 2020-07-12 09:50:45 -07:00
or-else 571b876626 bump version and rebuild 2020-07-06 09:17:32 -07:00
or-else 60867671aa rebuilt with german translation 2020-07-03 15:43:19 -04:00
or-else 1d46599a1f add german strings to service worker 2020-07-01 06:10:08 -04:00
or-else 04734a7dd9 Merge branch 'devel' of https://github.com/tinode/webapp into devel 2020-07-01 06:06:42 -04:00
or-else 37351f9fdf mention German translation 2020-07-01 06:06:25 -04:00
Gene 79fba3a402 Merge pull request #56 from natowi/patch-1
German translation
2020-07-01 12:10:34 +03:00
natowi 7c9cc230ac German translation 2020-06-30 21:19:59 +02:00
Gene ff2f6b035a Update issue templates 2020-06-28 15:43:43 +03:00
or-else b16d538942 another screenshot added 2020-06-23 17:40:12 +03:00
or-else abd94d6858 refreshed screenshots 2020-06-23 17:39:49 +03:00
or-else 6ab2d8663e bump version and rebuild 2020-06-23 10:42:42 +03:00
or-else 2576fa2a87 bump version and rebuild 2020-06-17 14:22:58 +03:00
or-else d0627d5370 visual and translation fixes 2020-06-11 22:03:23 +03:00
or-else a192129ff9 dependencies updated, breaking change in react-intl fixed 2020-06-08 18:20:12 +03:00
or-else ee6b87c3a9 add actual translations 2020-06-03 15:06:21 +03:00
or-else 7fccd41ced spanish translation by @sanmiguel2019 added 2020-06-03 15:03:30 +03:00
or-else b95e3269ea mention Korean translation in README 2020-06-03 11:01:35 +03:00
or-else 38c0023e23 korean translation included into build 2020-06-02 19:02:40 +03:00
Gene 1efc9a90cb Merge pull request #53 from kimtaeyoung-98/master
Korean translation of en.json
2020-06-01 20:11:33 +03:00
kimtaeyoung-98 de04006afc en.json문서의 한국어 번역
Removed spaces.
2020-06-02 01:37:13 +09:00
kimtaeyoung-98 d484bff407 Korean translation of en.json
Korean translation of en.json
2020-06-01 19:04:08 +09:00
or-else 7cd5f786b2 rebuilt with sdk fix: always close the socket 2020-05-24 11:04:48 +03:00
or-else 65c94c9f12 a few more i18n fixes 2020-05-10 14:26:36 +03:00
or-else c3e5b1cabc fix i18n formatting issues 2020-05-10 14:02:52 +03:00
or-else ad97c5638f rebuilt 2020-05-10 12:54:20 +03:00
or-else c9d05642e2 fix issue with scrolling on image preview 2020-05-10 12:53:50 +03:00
or-else 3410128d6f rebuilt 2020-05-09 09:25:47 +03:00
or-else b7a8d4d09d mute in addition to blocking 2020-05-08 11:06:53 +03:00
or-else 919047cfde define and use max tag count 2020-05-01 17:19:18 +03:00
or-else fbaf98c117 use standard inband limit 2020-04-29 15:17:13 +03:00
or-else 5d96331ef5 bump version and rebuild 2020-04-26 11:20:03 +03:00
or-else 587d457712 bump version and rebuild 2020-04-22 11:50:56 +03:00
or-else 979b283928 fix for hidden group-manager control panel 2020-04-21 21:16:54 +03:00
or-else 7c70dea398 bump version and rebuild 2020-04-20 12:01:26 +03:00
or-else 3f04d0e23b more localizations 2020-04-18 12:26:45 +03:00
or-else d953f6ac3e bump version and rebuild 2020-04-17 14:36:44 +03:00
or-else e5e78755ae blocked chats display 2020-04-17 14:30:26 +03:00
or-else 52c1016b45 fix disappearing [you] badge in topic members 2020-04-15 15:55:40 +03:00
or-else c0a3ba6a92 make topic and user deletions "hard" 2020-04-15 09:45:49 +03:00
or-else 6be7b87173 small refactoring of enabled permissions in topic 2020-04-15 09:45:15 +03:00
or-else 35a63ff5ab fix alert dialog 2020-04-13 19:00:38 +03:00
or-else 375750a6ff bump version and rebuild 2020-04-13 16:13:34 +03:00
or-else ea8ac3c293 refactoring of account settings into multiple screens 2020-04-13 16:01:24 +03:00
or-else 5507ccd728 refactoring account settings into multiple screens 2020-04-13 10:35:33 +03:00
or-else b96c8c2516 bump version and rebuild 2020-04-03 14:22:36 +03:00
or-else e04240870d rebuilt 2020-04-02 10:15:54 +03:00
or-else 506cd00b27 chrome bug was fixed 2020-04-02 08:18:38 +03:00
or-else cdd40facec support for incognito mode 2020-04-01 21:13:32 +03:00
or-else 6222610836 rebuilt with different background 2020-03-26 14:27:24 +03:00
or-else 16b5bdf37d Merge branch 'devel' of https://github.com/tinode/webapp into devel 2020-03-20 15:08:23 +03:00
or-else 6e39fe9bef use different background image for messages panel 2020-03-20 15:08:11 +03:00
Gene 78c2f1aaa1 Merge pull request #46 from mzch/master
Fix 2 bugs in Linux environment
2020-03-14 18:25:37 +03:00
Koichi MATSUMOTO 947b166935 move postcss-cli to devDependencies
move postcss-cli to devDependencies
2020-03-14 22:20:15 +09:00
Koichi MATSUMOTO 337afcf075 fix lack of postcss-cli
fix lack of postcss-cli
2020-03-14 20:08:04 +09:00
Koichi MATSUMOTO 77e67bb714 fix typo
fix typo
2020-03-14 20:02:40 +09:00
or-else dbcac419de replaced UglifyJsPlugin with TerserPlugin 2020-03-14 09:39:52 +03:00
or-else 475e5eb3e7 dependencies updated 2020-03-14 09:09:27 +03:00
or-else 12f05e8570 bump version and rebuild 2020-03-08 16:28:45 +03:00
or-else 527a01bebb critical bug: login stalls after refresh 2020-02-27 19:54:32 +03:00
or-else ffe2ab7737 bump version and rebuild 2020-02-25 09:30:06 +03:00
or-else 2fd8346dc5 check Drafty for validity 2020-02-23 21:44:08 +03:00
or-else fd4cb58f0c more prominent "messaging disabled" note 2020-02-23 20:46:22 +03:00
or-else d2647d5f0c fix: incorrect redirect on failed sign up 2020-02-23 13:26:24 +03:00
or-else e27c106c33 pass himan UI language to tinode-sdk 2020-02-23 10:57:04 +03:00
or-else e7840b05a6 removed debug printing 2020-02-18 14:34:40 +03:00
or-else 3484c6549e rebuilt with SDK fix 2020-02-18 14:01:35 +03:00
or-else 30054aec04 formatting 2020-02-14 09:59:35 +03:00
or-else fae274062a correct link to web-app icon 2020-02-14 09:58:35 +03:00
or-else 3b77af2a10 adding missing web-app.svg icon 2020-02-14 09:58:03 +03:00
or-else a4b757fa95 bump version and rebuild 2020-02-08 12:23:51 +03:00
or-else 9d7e1d4f63 rebuilt with fixed note-read & translations 2020-02-05 10:18:59 +03:00
or-else 858e62f732 google translate missing chinese strings 2020-02-04 18:05:58 +03:00
or-else 50a775fc72 fix missing read notifications 2020-01-22 21:01:05 +03:00
or-else feaefdb952 update and restructure for clarity 2020-01-22 11:05:02 +03:00
or-else 755ad00619 merge next 2020-01-21 22:09:45 +03:00
or-else c6e440ca72 merge 2020-01-21 16:18:19 +03:00
or-else f34f786bc6 bump version and rebuild 2020-01-21 14:24:21 +03:00
aforge b2e7a4d63c Merge pull request #43 from tinode/retry-context-menu
Add Retry option in context menu for failed messages.
2020-01-20 22:35:58 -08:00
aforge f100385c79 Minor syntax change. 2020-01-20 22:34:39 -08:00
aforge 0081ed3716 Add Retry option in context menu for failed messages. 2020-01-20 21:25:22 -08:00
or-else 065115d2bf done 2020-01-18 13:42:19 +03:00
or-else ed6518df9c almost works 2020-01-18 11:36:57 +03:00
or-else f7a52d70d6 first pass at file attachment previews 2020-01-18 11:15:37 +03:00
or-else 72d978ed55 previewing image before sending works now 2020-01-14 18:07:56 +03:00
or-else 90889454a0 fix placeholder text positioning 2020-01-13 19:25:07 +03:00
or-else d1c90f8434 more refactoring of image preview 2020-01-13 18:20:09 +03:00
or-else 8622926a4c first pass at implementing image preview before sending 2020-01-13 10:20:12 +03:00
or-else 2de35424b5 updated FCM instructions 2020-01-13 10:19:36 +03:00
or-else d603953a77 bump version and rebuild 2020-01-11 14:21:16 +03:00
or-else d0e3e238b8 missing "content deleted" when all content is deleted 2020-01-10 12:25:46 +03:00
or-else 83dfb5e9b2 fix for incorrect "Not found" user name in messages 2020-01-09 20:54:25 +03:00
or-else 727d926bf6 dependencies updated 2020-01-09 20:53:45 +03:00
or-else fe9f4bb17e bump version and rebuild 2020-01-05 16:34:26 -05:00
or-else 1afd876d99 updated instructions for setting up firebase in web app 2019-12-30 15:42:41 -05:00
or-else 6fee1ce5ea firebase made a breaking change in 7.0.0 - need different config 2019-12-30 15:31:39 -05:00
or-else 7828c372e3 better exclusion of node modules cache 2019-12-29 20:48:17 -05:00
or-else e02047345c check in .gitignore 2019-12-29 20:46:56 -05:00
or-else db76ec3266 dependencies updated 2019-12-23 10:27:34 +03:00
or-else 06de1c1fb6 bump version and rebuild 2019-12-23 09:54:35 +03:00
or-else a662787b9c fix for different representation of deleted messages 2019-12-12 15:58:21 +03:00
or-else bc4c0811d1 indicators of deleted content work now 2019-12-06 10:41:21 +03:00
or-else 893cbdaa0c does not crash on deleted messages 2019-12-03 13:01:23 +03:00
or-else 44c5e30f4d all dependencies updated 2019-11-30 12:16:29 +03:00
or-else b81ec3e634 update dependencies and bump version 2019-11-30 11:21:10 +03:00
or-else b48e4e63d9 rebuilt 2019-11-20 11:49:06 +03:00
or-else dce1875de4 simplify handling of new message notifications 2019-11-20 11:44:15 +03:00
or-else 3392bf5ed7 make sure unread is > 0 before playing a sound 2019-11-20 11:40:36 +03:00
or-else 99de97b2e1 pres.act is not set when the message is sent by the current user 2019-11-20 11:18:52 +03:00
or-else d30b616b26 bump version and rebuild 2019-11-18 09:25:30 +03:00
or-else aa7cb55c8e better presentation of unnamed contacts 2019-11-13 20:18:21 +03:00
or-else 9e756c6772 bump version and rebuild 2019-11-12 15:54:12 +03:00
or-else 8ee50adf4d fix base64 preprocessing 2019-11-12 15:53:52 +03:00
or-else 4c28753e6a show different message when search returned no contacts 2019-11-08 17:43:41 +03:00
or-else 1cdb8b724c rebuilt 2019-11-06 11:28:30 +03:00
or-else 6a2efac566 and nother place where title length is enforced 2019-11-06 11:27:49 +03:00
or-else 8c1b926008 enforce maximum title length for topics and users 2019-11-06 11:25:07 +03:00
or-else c539e5f70d another Russian translation fix 2019-11-06 11:24:45 +03:00
or-else 154ec5000e rebuilt 2019-11-06 11:03:52 +03:00
or-else 80c5103ce0 UI fix for excessively long topic name 2019-11-06 10:33:21 +03:00
or-else 15c0ff2e75 more idiomatic Russian translation 2019-11-06 10:33:02 +03:00
or-else a404734794 bump version and rebuild 2019-11-03 09:44:28 +03:00
or-else f962c8e8a9 updated dependencies 2019-11-02 09:58:34 +03:00
or-else bb20937bc9 replace unpkg with jsdeliver as unpkg is too unreliable https://github.com/mjackson/unpkg/issues/229 2019-11-01 09:41:06 +03:00
or-else 3026e21b46 updated Missing Functionality section 2019-11-01 09:39:38 +03:00
or-else 92b9faa063 explicitly ignore "upd" presence updates 2019-11-01 09:39:05 +03:00
or-else a1894871aa updated for the next tindroidX package 2019-11-01 09:37:56 +03:00
or-else bd03a7164e fix translation to most common spelling 2019-10-25 08:24:53 +03:00
or-else 9891620188 added "mobile-web-app-capable" meta header 2019-10-23 19:31:59 +03:00
or-else afa0726bdf bump version and rebuild 2019-10-21 09:12:59 +03:00
or-else 1a927e5420 rebuilt with fixed url 2019-10-16 13:05:58 +03:00
Gene 5f4db76cbf Merge pull request #41 from tinode/fix-privacy-link
Fix broken privacy policy link.
2019-10-16 13:04:15 +03:00
aforge 4092324fea Fix broken privacy policy link. 2019-10-15 23:34:56 -07:00
or-else efc7377942 rebuilt with tinode-js fix 2019-10-14 17:01:30 +03:00
or-else 94f88855de rebuilt with tinode-js 2019-10-12 09:40:04 +03:00
or-else 73afafb605 rebuilt because of change in tinode-js: handling of {pres what=term}, tinode/chat#278 2019-10-12 09:00:18 +03:00
or-else fe556a0707 mark tinode.dev.js and tinode.prod.js as binary 2019-10-10 11:59:20 +03:00
or-else 79d0c6dd14 attempted fix for tinode/chat#278 2019-10-10 11:57:23 +03:00
or-else 8e9349b58b split tinode-sdk into a separate file 2019-10-03 18:15:29 +03:00
or-else 22710adbe3 add spinner to login action, small build changes 2019-10-03 16:48:47 +03:00
or-else b8a1b39bba bump version and rebuild 2019-10-02 10:44:59 +03:00
or-else ef341cb94e destructive menu action wrapped in an alert 2019-09-27 16:55:19 +03:00
or-else cda001e5c6 fix: new group topic is immediately unsubscribed 2019-09-26 19:25:18 +03:00
or-else 254c4768eb badges were broken 2019-09-25 14:36:19 +03:00
or-else 8f0125587f add firefox scrollbar styling 2019-09-24 20:50:19 +03:00
or-else 1aaf6f8fab rebuilt after fixing big in deleting messages 2019-09-24 20:04:52 +03:00
or-else 1d56d55487 adding alert component 2019-09-24 19:40:36 +03:00
or-else 205ca95217 bug fix: archive view does not render 2019-09-24 19:26:59 +03:00
or-else f9a29ed3ef add alert for destructable actions 2019-09-24 17:12:38 +03:00
or-else f3420dc66f upgraded react-intl to 3.3 2019-09-23 15:28:22 +03:00
or-else 6d581c8e7c rename chinese translation from zh-CN to zh 2019-09-23 14:15:57 +03:00
or-else 10bb206d51 experimenting with code splitting 2019-09-22 21:32:32 +03:00
or-else a2747b6e33 clarify use of explicit UI human language 2019-09-22 21:32:06 +03:00
or-else b3b0ae59ab updated dependencies 2019-09-22 21:31:33 +03:00
or-else d99434000f clarify group member editing UI (disallow deletes for S only) 2019-09-22 21:30:50 +03:00
or-else 2a164ddf49 links for blocking/reporting users and topics 2019-09-22 21:19:04 +03:00
or-else 12a0660e5b shorthand replacements for React.Fragment and <span> 2019-09-22 21:18:31 +03:00
or-else 2502460143 translations added, minor visual tweaks, version bumped 2019-09-20 14:37:29 +03:00
or-else c31631b69f ToS added, small bug in CSS fixed 2019-09-19 12:57:42 +03:00
or-else 711cf7c690 fix: crash on leaving the topic 2019-09-04 20:12:15 +03:00
or-else 3669ed7645 bump version and rebuild 2019-09-03 14:55:07 +03:00
or-else 41ebf3c705 fix: #37; correct case for auto* in LoginView 2019-09-02 18:49:31 +03:00
or-else 6f359be64d make webpack path universal / vs \\ 2019-08-29 20:03:57 +03:00
or-else d472a321de no autocorrection for username 2019-08-26 12:26:10 +03:00
or-else eda9f0f7f9 fix: exception in send-message.jsx on Enter 2019-08-24 18:04:14 +03:00
or-else d9c729d9ab update dependencies and rebuild 2019-08-24 14:31:55 +03:00
or-else 78b0909f02 add open graph headers 2019-08-24 14:13:33 +03:00
or-else 61a9b6c56f minify logo.svg and bump version 2019-08-22 11:55:23 +03:00
or-else a6bc2a1f8f migrate the app from api.. to web.. 2019-08-22 10:46:14 +03:00
or-else 503c9128d4 fix: wrong bubble tip color in Safari 2019-08-21 17:12:47 +03:00
or-else 1b4bba051e fix blob: URLS, corractly calculate image preview viwport size 2019-08-17 20:07:15 +03:00
or-else c14fb857bc fix for possible xss exploits 2019-08-17 16:56:15 +03:00
or-else be528ff282 bum version and rebuild 2019-08-15 11:24:49 +03:00
or-else fb9461834a refactored getDerivedStateFromProps in in-place-edit 2019-08-15 10:49:24 +03:00
or-else 0217d84721 refactoring due to facebook/react/pull/15047 2019-08-15 10:30:29 +03:00
or-else f1744c3f69 getDerivedStateFromProps refactored in contacts-view and chips-input, #35 2019-08-14 20:24:07 +03:00
or-else a79373c8cd replace getDerivedStateFromProps with componentDidUpdate 2019-08-12 11:51:44 +03:00
or-else d2975f0c20 fix avatar not being updated while edited 2019-08-06 17:18:28 +03:00
or-else d782fb8eab bump version and rebuild 2019-08-02 09:06:46 +03:00
or-else bdf152e5d6 fix: unable to delete profile picture 2019-08-01 09:53:41 +03:00
or-else 40edad1c99 fixed: unable to remove topic avatar 2019-08-01 09:49:26 +03:00
or-else 7239aae8aa fix typo, bump versin and rebuild 2019-07-26 11:48:19 +03:00
or-else 81c789b713 push notification instructions updated 2019-07-20 12:12:26 +03:00
or-else 744da99f30 don't play new message sound for achived topics 2019-07-18 13:35:39 +03:00
or-else 112ce08fde updated dependencies and rebuilt 2019-07-17 13:51:59 +03:00
or-else 3fd01648a8 bump version and rebuild 2019-07-08 17:29:00 +03:00
or-else 619ec15920 update one translation 2019-06-26 16:08:54 -07:00
or-else a6a097b15f translations 2019-06-20 20:26:43 -07:00
or-else 450a2f6975 translation tweaks 2019-06-16 12:09:47 -07:00
or-else e006043290 rebuilt with possible fix for https://github.com/tinode/chat/issues/242 2019-06-15 09:46:39 -07:00
or-else 34db50280e rebuild 2019-06-13 09:41:31 -07:00
or-else 6268fb68f8 minor fixes 2019-06-12 18:27:24 -07:00
or-else 9a310a0438 handle credential confirmation from EditAccountView 2019-06-12 11:03:21 -07:00
or-else 821ab6a16f dependencies updated 2019-06-11 17:01:11 -07:00
or-else f9e78bf280 credential management 2019-06-09 19:57:41 -07:00
or-else 83488ca878 rebuild 2019-06-07 16:27:26 -07:00
246 changed files with 23369 additions and 28258 deletions
-9
View File
@@ -1,9 +0,0 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"comments": false,
"plugins": [
["react-intl", {
"messagesDir": "./src/i18n/"
}]
]
}
-5
View File
@@ -1,5 +0,0 @@
index.dev.js binary
index.dev.js.map binary
index.prod.js binary
index.prod.js.map binary
base.min.css binary
+73
View File
@@ -0,0 +1,73 @@
name: Deploy im-webapp production
on:
push:
branches:
- pro
workflow_dispatch:
concurrency:
group: production-im-webapp
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/im_webapp
SERVICE: im-webapp
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs:
deploy:
runs-on: deploy-host
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
- name: Build and push image
env:
TINODE_API_KEY: ${{ secrets.TINODE_API_KEY }}
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
TINODE_API_KEY="${TINODE_API_KEY#\'}"
TINODE_API_KEY="${TINODE_API_KEY%\'}"
docker build \
--build-arg TINODE_API_KEY="${TINODE_API_KEY}" \
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
install -d -m 700 "$HOME/.ssh"
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
cleanup() {
shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"cd /opt/olns && docker compose pull im-webapp && docker compose up -d --no-deps --wait im-webapp"
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
.git.worktree-pointer.bak
node_modules
.idea/
*.code-workspace
# Local developer overrides — never commit real keys.
.env.local
.claude/
-25
View File
@@ -1,25 +0,0 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroid",
"sha256_cert_fingerprints":
["AA:0A:FF:C4:F3:1F:58:83:F3:8E:69:03:2E:B9:AA:1A:C4:62:65:F1:1E:50:F9:7E:AD:35:56:55:32:B7:1A:C6"]
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "web",
"site": "https://api.tinode.co"
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroid",
"sha256_cert_fingerprints":
["AA:0A:FF:C4:F3:1F:58:83:F3:8E:69:03:2E:B9:AA:1A:C4:62:65:F1:1E:50:F9:7E:AD:35:56:55:32:B7:1A:C6"]
}
}]
+43
View File
@@ -0,0 +1,43 @@
# ---- Stage 1: build static assets ----
# The active frontend lives under web/ (Vite + React + TypeScript). The legacy
# root-level Webpack application has been removed; production must only build web/.
FROM node:22-alpine AS build
WORKDIR /app/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/. .
# VITE_TINODE_API_KEY is read by src/lib/config.ts (import.meta.env.VITE_TINODE_API_KEY) at build
# time -- Vite only exposes env vars prefixed VITE_ to client code, so the ARG is renamed on its
# way into the build's environment rather than passed through under its old webpack-era name.
ARG TINODE_API_KEY=
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
# The production web UI and IM REST API use different origins. Local development leaves this unset
# and continues to use Vite's same-origin /v1/im proxy.
ARG TINODE_API_BASE=
ENV VITE_TINODE_API_BASE=$TINODE_API_BASE
# Leave the file origin unset in production so downloads use the current site's /v0/file route.
# A build-time override remains available for isolated development deployments.
ARG TINODE_FILE_BASE=
ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE
RUN npm run build
# ---- Stage 2: serve with nginx ----
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /usr/share/nginx/html
# Vite's build output is fully self-contained: index.html, public assets and
# content-hashed JS/CSS chunks.
COPY --from=build /app/web/dist/. ./
HEALTHCHECK --interval=1m --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null http://localhost/ || exit 1
EXPOSE 80
+31 -39
View File
@@ -1,52 +1,44 @@
# Single-page chat with Tinode
# 迅工捷使 Web
Single-page web chat application built with [Tinode](https://github.com/tinode/chat/). The app uses
[React](https://facebook.github.io/react/), Google's material design [fonts](https://www.google.com/fonts/)
and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web). The [Tinode javascript SDK](https://github.com/tinode/tinode-js/) has no external dependencies.
Overall it's a lot like open source WhatsApp or Telegram web apps.
当前前端位于 `web/`,技术栈为 React、TypeScript 和 Vite。仓库根目录不再保留旧版 Tinode Webpack 应用及其构建产物。
The app is about 200KB minified and gzipped. That includes all dependencies (React, ReactDOM), fonts, graphics, css etc.
## 本地开发
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist. The app was tested in the latest Chrome & Firefox only. NPM package is available at https://www.npmjs.com/package/tinode-webapp.
```bash
cd web
npm ci
npm run dev
```
Try possibly newer or older version live at https://api.tinode.co/. For passwords for demo accounts and other instructions [see here](https://github.com/tinode/chat/#demo).
## 检查与构建
## Getting support
```bash
cd web
node --test tests/*.test.mjs tests/*.test.ts
npm run lint
npm run build
```
* Read [client-side](http://tinode.github.io/js-api/) and [server-side](https://github.com/tinode/chat/blob/master/docs/API.md) API documentation.
* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
* For bugs and feature requests [open an issue](https://github.com/tinode/webchat/issues/new).
生产构建输出到 `web/dist/`。
## Internationalization
## Docker 部署
The app is fully internationalized using [React-Intl](https://github.com/yahoo/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `hl=XX` parameter to the URL, i.e. https://api.tinode.co/#?hl=ru.
根目录 `Dockerfile` 只复制并构建 `web/`:
As of the time of this writing the following translations exist: English, Russian, Simplified Chinese. More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json), [Russian](/src/i18n/ru.json), or [Simplified Chinese](/src/i18n/zh-CN.json) translations for guidance.
```bash
docker build \
--build-arg TINODE_API_KEY="<部署密钥>" \
--build-arg TINODE_FILE_BASE="https://api.118im.com/v0/file" \
-t im-webapp .
```
生产环境通过 `TINODE_FILE_BASE` 固定附件下载入口;本地开发不设置该变量时,继续使用租户解析结果和 Vite 代理。
## Missing functionality
测试集群通过 Gitea Actions 的 `Deploy im-webapp (k3s test cluster)` 工作流手动发布。
* Previews not generated for videos, audio, links or docs.
* No end-to-end encryption.
* Emoji support is weak.
## 目录
## Other
Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
Background patterns from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
## Responsive design
### Desktop screenshot
<p align="center">
<img src="web-desktop-2.png" alt="Desktop web: full app" width=866 />
</p>
### Mobile screenshots
<p align="center">
<kbd><img src="web-mob-contacts-1.png" alt="Mobile web: contacts" width=323 /></kbd> <kbd><img src="web-mob-chat-1.png" alt="Mobile web: chat" width=323 /></kbd> <kbd><img src="web-mob-info-1.png" alt="Mobile web: topic info" width=323 /></kbd> <kbd><img src="web-mob-new-chat-1.png" alt="Mobile web: start new chat" width=323 /></kbd>
</p>
- `web/`:当前 Vite 前端源码、测试和静态资源
- `docker/`:Nginx 配置
- `docs/`:设计及实施文档
- `.gitea/workflows/`:测试集群部署工作流
BIN
View File
Binary file not shown.
-1906
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The webapp uses real (History API) client-side routing, so any unknown path can safely fall
# back to index.html -- the app resolves the actual view/topic client-side from the path itself
# once index.html loads. index.html itself is never content-hashed, so it must always be
# revalidated -- otherwise a browser holding a cached copy would keep requesting the *previous*
# deploy's now-deleted /assets/*.js filenames after a new deploy replaces them.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
# Vite content-hashes every filename under assets/ (e.g. index-DYjopaLI.js) -- a new build never
# reuses an old asset's URL, so these can be cached aggressively and indefinitely; a stale cached
# copy simply never gets requested again once index.html points at the new hashed filenames.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
@@ -0,0 +1,92 @@
# Web Clipboard Image Compatibility Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Broaden the new Web composer's image-paste formats without changing immediate send or native text paste.
**Architecture:** Extract clipboard inspection into a small dependency-free module. The React paste callback synchronously snapshots event data and calls `preventDefault()` only for an image or an image-looking fallback; asynchronous data-URL/Clipboard API decoding then feeds the existing `sendImage` path.
**Tech Stack:** React 19, TypeScript, Vite, Node 25 built-in `node:test`, Playwright CLI.
---
## File map and boundaries
- Create `web/src/features/messages/clipboard-image.ts`: clipboard format selection, HTML data-image extraction, async Blob conversion, Clipboard API fallback. It must not import React, Tinode, or UI state.
- Create `web/tests/clipboard-image.test.ts`: direct behavior tests for the helper; no new test dependency.
- Modify `web/src/features/messages/ConversationView.tsx:939-962`: replace the narrow paste loop and reuse existing `sendImage`/error toast. Do not change `handlePickImage`, `sendImage`, drafts, or other views.
- Modify `web/package.json`: add `test:clipboard` invoking `node --test tests/clipboard-image.test.ts`.
## Task 1: Clipboard extraction with test-first coverage
**Files:** Create `web/tests/clipboard-image.test.ts`; create `web/src/features/messages/clipboard-image.ts`; modify `web/package.json`.
- [ ] **Step 1: Add a failing assertion and API stubs.** In the test, import `firstImageFile`, `htmlDataImageUrl`, `shouldReadClipboard`, `fileFromDataImageUrl`, and `readClipboardImage` from `../src/features/messages/clipboard-image.ts`. Use `node:test`/`node:assert/strict`. Start with `new File(['png'], 'shot.png', {type:'image/png'})` in `items[0].getAsFile()` and assert `firstImageFile(data) === file`. Export typed stub functions returning `null`/`false` in the new helper solely so the test runs to an assertion failure.
```ts
import assert from 'node:assert/strict';
import test from 'node:test';
import { firstImageFile } from '../src/features/messages/clipboard-image.ts';
test('reads an image file from clipboard items', () => {
const file = new File(['png'], 'shot.png', { type: 'image/png' });
const data = { items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], files: [] } as unknown as DataTransfer;
assert.equal(firstImageFile(data), file);
});
```
- [ ] **Step 2: Run `cd web && node --test tests/clipboard-image.test.ts`; confirm the first assertion fails (`actual: null`, expected File), not a loader/type error.**
- [ ] **Step 3: Implement the smallest `firstImageFile(data: DataTransfer | null): File | null` that checks each `kind=file` item via `getAsFile()`, accepts `file.type` or an image `item.type`, then checks `data.files` even when `items` exists. If only `item.type` supplies MIME, wrap the File with that MIME. Return the first image only.**
- [ ] **Step 4: Rerun the focused test; confirm pass. Add one failing test at a time for JPEG/BMP, empty `item.type` with valid File MIME, null `getAsFile()` followed by `files`, a non-image file, and two images selecting the first. Run red then green for each.**
- [ ] **Step 5: Add failing HTML tests:** an `<img src="data:image/png;base64,...">` URL is returned; a remote `https:` image, plain HTML text, and a `data-src` attribute are not. Implement `htmlDataImageUrl(data)` by reading `getData('text/html')` synchronously and matching only an actual `src` attribute on an `<img>`, with `data:image/` scheme. Add a failing test that `fileFromDataImageUrl()` converts a valid data URL to an image File and rejects malformed data. Implement it with `fetch(dataUrl)`, `Blob`, and `new File(...)`; never fetch a non-data URL.
- [ ] **Step 6: Add failing fallback-decision tests:** `shouldReadClipboard(data)` is false for plain text and ordinary HTML, true for a file/image hint without usable file or a completely empty event. Implement with `types`, `items`, and `getData('text/plain')`, without asynchronous event reads.
- [ ] **Step 7: Add failing Clipboard API tests:** injected `read()` returning a `ClipboardItem` with `image/png` produces a File; a text-only item returns null; permission rejection propagates. Implement `readClipboardImage(clipboard?: Pick<Clipboard,'read'>): Promise<File | null>` by trying image MIME types, `getType()`, and wrapping the Blob. No navigator access inside this helper, so tests can inject a stub.
```ts
const clipboard = { read: async () => [{ types: ['image/png'], getType: async () => new Blob(['png'], { type: 'image/png' }) }] } as unknown as Pick<Clipboard, 'read'>;
const image = await readClipboardImage(clipboard);
assert.equal(image?.type, 'image/png');
```
- [ ] **Step 8: Add `"test:clipboard": "node --test tests/clipboard-image.test.ts"` to `web/package.json`. Run `npm run test:clipboard`; expected all tests pass. Commit only these files with `git add web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts web/package.json && git commit -m 'test: cover clipboard image formats'`.**
## Task 2: Integrate with the composer
**Files:** Modify `web/src/features/messages/ConversationView.tsx:939-962`; test `web/tests/clipboard-image.test.ts`.
- [ ] **Step 1: Add a failing orchestration test:** introduce `handleImagePaste(data, preventDefault, onImage, onError, clipboard)` as a named export and test that PNG calls `preventDefault` synchronously and then `onImage`, plain text does neither, HTML data-image claims before awaiting conversion, and Clipboard API failure calls `onError`. Run this test red (first as a typed stub returning `Promise.resolve(false)`), then implement the orchestrator in `clipboard-image.ts`. It must call `preventDefault` before its first `await`, and must not catch an exception thrown by `onImage` as a clipboard-read failure.
```ts
let prevented = false;
let sent: File | null = null;
const pending = handleImagePaste(data, () => { prevented = true; }, file => { sent = file; }, () => { throw new Error('unexpected'); });
assert.equal(prevented, true);
await pending;
assert.equal(sent, file);
```
- [ ] **Step 2: Replace the existing `handleComposerPaste` loop with a thin call to `handleImagePaste`.** Pass `e.clipboardData`, `() => e.preventDefault()`, a callback that calls `consumePendingReply()` then the existing `sendImage(topic,file,reply).catch(...)`, a callback that calls `showError(undefined, '无法读取剪贴板图片')`, and `navigator.clipboard`. This preserves existing send behavior and does not retain the React event across an asynchronous boundary. Do not broaden to previews or multi-image sends.
```tsx
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
void handleImagePaste(
e.clipboardData,
() => e.preventDefault(),
(file) => {
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
},
() => showError(undefined, '无法读取剪贴板图片'),
navigator.clipboard,
);
};
```
- [ ] **Step 3: Run `cd web && npm run test:clipboard && npm run build && npm run lint`; all commands must exit 0. Review `git diff --check` and `git diff -- web/src/features/messages/ConversationView.tsx` for scope and native-text behavior. Commit only the touched files with `git add web/src/features/messages/ConversationView.tsx web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts && git commit -m 'fix: support more clipboard image formats'`.**
## Task 3: Browser verification and handoff
**Files:** No production changes unless a targeted test finds a defect.
- [ ] **Step 1: Start local Vite (`cd web && npm run dev -- --host 127.0.0.1`) and use Playwright CLI on the local origin. Import `/src/features/messages/clipboard-image.ts` in the browser, paste a real PNG ClipboardItem, and verify it appears as `kind=file`, `type=image/png`, and is selected. Separately dispatch a text-only ClipboardEvent and confirm `shouldReadClipboard` is false and native default is not prevented. Test HTML data image selection and remote HTML exclusion.**
- [ ] **Step 2: Rerun `npm run test:clipboard`, `npm run build`, `npm run lint`, and `git diff --check` fresh after any fixes. Check `git status --short` and report exact commits/files. Do not push or deploy. Explain that macOS Chromium/browser-event simulation is not a real Windows WeChat end-to-end test.**
## Self-review against the approved spec
Items/files, MIME fallback, HTML data image, Clipboard API, text passthrough, error feedback, first-image-only behavior, no remote fetch, test-first, browser verification, and no deployment are all assigned above. The only unavoidable limit is native Windows clipboard conversion before JavaScript receives the event; the final report must not claim that was verified.
@@ -0,0 +1,151 @@
# Web Brand And Message Sound Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将生产 Web 品牌统一为“迅工捷使”,精简登录页,并为符合提醒条件的新消息播放用户提供的提示音。
**Architecture:** 继续以 `web/src/lib/config.ts` 作为显示品牌名来源,认证页和系统消息只消费该常量;品牌图标和 MP3 作为 Vite `public/` 静态资源。消息声音接入现有 `push.ts` 新消息扫描链路,在浏览器通知权限判断之前复用“本人消息、当前可见会话、会话免打扰”的过滤结果。
**Tech Stack:** React 19、TypeScript、Vite、Node test runner、Jiti。
---
### Task 1: 写品牌和提示音红测
**Files:**
- Create: `web/tests/brand-and-notification-sound.test.mjs`
- [ ] **Step 1: 写失败测试**
创建 Node 测试,核心断言如下;可按实际导出补齐 fake `Audio` 的类型字段,但不得降低断言:
```js
const webRoot = new URL('../', import.meta.url);
assert.match(await readFile(new URL('index.html', webRoot), 'utf8'), /<title>迅工捷使<\/title>/);
assert.doesNotMatch(await readFile(new URL('src/features/auth/LoginPage.tsx', webRoot), 'utf8'), /桌面端|下载桌面客户端|iOS \/ Android|帮助中心/);
assert.doesNotMatch(await readFile(new URL('src/features/auth/TenantEntryPage.tsx', webRoot), 'utf8'), /MeetingIM/);
assert.doesNotMatch(await readFile(new URL('src/features/system/SystemMessagesView.tsx', webRoot), 'utf8'), /MeetingIM 安全团队/);
assert.equal(audioSource, '/message-notification.mp3');
assert.equal(playCount, 1);
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node --experimental-strip-types --test tests/brand-and-notification-sound.test.mjs`
Expected: FAIL,因为品牌仍是 `MeetingIM`,提示音模块和静态资源尚不存在。
### Task 2: 更新品牌和登录页
**Files:**
- Modify: `web/index.html`
- Modify: `web/src/lib/config.ts`
- Modify: `web/src/features/auth/LoginPage.tsx`
- Modify: `web/src/features/auth/TenantEntryPage.tsx`
- Modify: `web/src/features/system/SystemMessagesView.tsx`
- Create: `web/public/app-icon.png`
- Delete: `web/public/favicon.svg`
- Modify: `web/src/lib/notifications/tabIndicator.ts`
- Modify: `web/src/lib/notifications/push.ts`
- [ ] **Step 1: 复制官方品牌图标**
将 `../im-ios-website/dist/assets/app-icon.png` 原样复制为 `web/public/app-icon.png`,所有 Web 图标引用统一指向它。
- [ ] **Step 2: 最小化更新可见品牌**
把浏览器标题、认证页和系统消息署名改为“迅工捷使”;`APP_NAME` 保留 `MeetingIM/<version>` 作为现有 Tinode 客户端技术标识,避免把显示品牌替换扩大成协议改名。
```ts
export const BASE_APP_NAME = '迅工捷使';
export const APP_NAME = `MeetingIM/${__APP_VERSION__}`;
```
认证页统一使用:
```tsx
<img src="/app-icon.png" alt="" className="h-full w-full rounded-[inherit]" />
<strong>{BASE_APP_NAME}</strong>
```
- [ ] **Step 3: 精简登录页**
说明文案改为“单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。”,删除底部三个入口。
```tsx
<p className="mt-4 max-w-md text-base text-white/80">
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
</p>
```
### Task 3: 接入消息提示音
**Files:**
- Create: `web/src/lib/notifications/sound.ts`
- Modify: `web/src/lib/notifications/push.ts`
- Create: `web/public/message-notification.mp3`
- [ ] **Step 1: 复制用户提供的提示音**
将 `/Users/aa/Downloads/universfield-new-notification-012-363675.mp3` 原样复制为 `web/public/message-notification.mp3`。
- [ ] **Step 2: 实现安全播放器**
首次需要时创建 `Audio('/message-notification.mp3')`,每次播放前设置 `currentTime = 0`;对同步异常和 `play()` Promise 拒绝均静默处理。
```ts
let messageAudio: HTMLAudioElement | null = null;
export function playMessageSound(): void {
try {
messageAudio ||= new Audio('/message-notification.mp3');
messageAudio.currentTime = 0;
void messageAudio.play().catch(() => {});
} catch {
// Optional audio must never interrupt message delivery.
}
}
```
- [ ] **Step 3: 接入新消息过滤链路**
把“本人消息、当前可见会话、会话免打扰”抽成通知与声音共享的判断。通过判断后先播放声音;仅当 Notification API 可用且权限为 `granted` 时再显示系统通知。
```ts
if (!shouldAlert(topicName, latest)) return;
playMessageSound();
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
fireNotification(topicName, latest);
}
```
- [ ] **Step 4: 跑绿测**
Run: `node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs`
Expected: 现有 6 项和新增测试全部 PASS。
### Task 4: 验证、提交与部署
**Files:**
- Verify only
- [ ] **Step 1: 静态验证**
Run: `npm run build && npm run lint`
Expected: 构建退出码 0;lint 无 error,允许记录既有 warning。
- [ ] **Step 2: 完整性验证**
Run: `git diff --check`
Expected: 无输出,退出码 0;MP3 和应用图标的 SHA-256 与来源文件一致。
- [ ] **Step 3: 提交并推送**
提交精确修改文件到 `dev`,推送前先 fetch 并确认无远端分叉。
- [ ] **Step 4: 部署和公网回归**
触发 Gitea `deploy.yml`,等待成功,确认 K3s Deployment 2/2;访问 `https://web.118im.com` 验证标题、品牌、登录页删减及双账号新消息提醒路径。
@@ -0,0 +1,23 @@
# Web 聊天图片粘贴兼容设计
日期:2026-09-29。范围仅限 `web/` 新版聊天输入框的图片粘贴;不改旧版、后端、图片选择器或部署配置。
## 现状与目标
当前输入框只读取 `clipboardData.items` 中 `kind=file` 且 `item.type` 为 `image/*` 的首个项目。Windows 微信截图在不同浏览器中的剪贴板暴露形式尚无现场记录,因此不能断言根因;但 `files`、文件本身 MIME、内嵌 HTML 图片和浏览器 Clipboard API 均是当前实现未覆盖的可读路径。线上已经部署了现有粘贴功能。此前“多图暂存并配文字发送”按用户反馈撤回,本次保持**识别到首张图片后立即发送**,不加入预览、多图或图文合并。
## 方案
把图片提取逻辑放入一个可独立测试的小模块,输入为粘贴事件中的 `DataTransfer`,输出为首个可发送的 `File` 或“未取得图片”。按顺序尝试:
1. 同步检查 `items`:对 `kind=file` 调用 `getAsFile()`,以所得文件的 MIME 为准,`item.type` 仅作补充;然后检查 `files`,避免浏览器只填充其中一个集合。
2. 同步检查 `text/html` 中的 `<img src="data:image/...">`;只解码本地内嵌图片,不请求远程 URL,不执行 HTML。纯文字和普通 HTML 粘贴保持浏览器原生行为。
3. 对没有可用同步图片、也没有纯文字,且事件带图片/文件线索或完全没有数据类型的情况,尝试 `navigator.clipboard.read()` 读取浏览器提供的 `image/*` Blob。仅含普通 HTML 的事件不走此兜底。此路径可能受权限或浏览器支持限制;网页无法越过浏览器直接读取 Windows 原生 CF_DIB 等格式。
只有确定要接管图片粘贴或进入无文字的异步兜底时,才在事件回调内同步 `preventDefault()`。不得在 `await` 之后访问原粘贴事件的数据。提取成功后仍调用现有 `sendImage`;提取或读取失败时对明确的图片粘贴给出简短提示,发送失败继续沿用现有错误提示。不得让纯文字粘贴触发发送或权限请求。
## 验收与测试
先写失败测试,再改实现。覆盖:`items` 的 PNG/JPEG/BMP、`item.type` 缺失但文件 MIME 正确、仅 `files`、`getAsFile()` 为空时回退、HTML 内嵌图片、Clipboard API 返回图片、权限拒绝、纯文字原生粘贴、远程 HTML 图片不抓取、多个图片只取首张。运行定向测试、前端构建和 lint;再用真实浏览器派发图片及文字粘贴事件,核对是否接管和既有即时发送路径。若无 Windows/微信运行环境,最终明确说明这一端到端组合尚未实测,不宣称已覆盖所有微信版本。
本次只做本地代码与验证;不推送、不部署。
@@ -0,0 +1,34 @@
# Web 品牌与消息提示音设计
## 目标
将当前生产 Web 的用户可见品牌统一为“迅工捷使”,精简登录页指定文案,并使用用户提供的 MP3 为新收到的消息播放提示音。
## 范围
- 当前 Dockerfile 实际构建的 `web/` 目录。
- 浏览器标题、企业码页、登录页、系统消息署名和应用图标。
- 登录页说明改为“消息与回执在手机端保持一致”,删除底部三个入口。
- 复用 Android/iOS 已有蓝底扳手图标。
- 新消息提示音使用用户提供的 `universfield-new-notification-012-363675.mp3`。
## 行为
- 只为他人发来的新消息播放一次提示音。
- 正在可见地查看对应会话时不播放。
- 会话设置为消息免打扰时不播放。
- 声音播放失败不得影响消息接收、列表更新或浏览器通知。
- 浏览器系统通知权限不控制声音;声音与现有通知共用同一条新消息判定链路。
## 明确不改
- 后端代码和后端默认推送、短信名称。
- `X-MeetingIM-APIKey`、`X-MeetingIM-Auth` 等协议头。
- `meetingim-session` 等已有存储键。
- 仓库名、镜像名、域名和旧版未部署 Web 入口。
## 验证
- 自动测试覆盖品牌文字、登录页删减、品牌图片引用和提示音播放器。
- Web 全量测试、构建、lint 和 `git diff --check`。
- 部署后实测企业码页、登录页、浏览器标题、图标,以及双账号新消息提示音路径。
-9
View File
@@ -1,9 +0,0 @@
// Get these values for your project from the https://console.firebase.google.com/
const FIREBASE_INIT = {
// Project Settings -> Cloud Messaging, "Sender ID".
messagingSenderId: "114126160546",
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
// This value IS NOT included in the generated js config you can download from
// the firebase console. It needs to be added separately.
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
};
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

-305
View File
@@ -1,305 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="192"
height="192"
viewBox="0 0 192 192"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="logo.svg"
inkscape:export-xdpi="120"
inkscape:export-ydpi="120">
<defs
id="defs4">
<linearGradient
inkscape:collect="always"
id="linearGradient16239">
<stop
style="stop-color:#000000;stop-opacity:0.25098041"
offset="0"
id="stop16241" />
<stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop16243" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient16239"
id="linearGradient16249"
x1="102.81445"
y1="97.075966"
x2="219.01871"
y2="170.4807"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.293059,0,0,1.0059594,-43.958501,0.80696392)" />
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter16588">
<feFlood
flood-opacity="0.25098"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood16590" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite16592" />
<feGaussianBlur
in="composite1"
stdDeviation="0"
result="blur"
id="feGaussianBlur16594" />
<feOffset
dx="-0"
dy="-1"
result="offset"
id="feOffset16596" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="fbSourceGraphic"
id="feComposite16598" />
<feColorMatrix
result="fbSourceGraphicAlpha"
in="fbSourceGraphic"
values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"
id="feColorMatrix4300" />
<feFlood
id="feFlood4302"
flood-opacity="0.25098"
flood-color="rgb(0,0,0)"
result="flood"
in="fbSourceGraphic" />
<feComposite
id="feComposite4304"
in2="fbSourceGraphic"
in="flood"
operator="in"
result="composite1" />
<feGaussianBlur
id="feGaussianBlur4306"
in="composite1"
stdDeviation="0"
result="blur" />
<feOffset
id="feOffset4308"
dx="0"
dy="-3"
result="offset" />
<feComposite
id="feComposite4310"
in2="offset"
in="fbSourceGraphic"
operator="over"
result="composite2" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4539">
<feFlood
flood-opacity="0.196078"
flood-color="rgb(255,255,255)"
result="flood"
id="feFlood4541" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="out"
result="composite1"
id="feComposite4543" />
<feGaussianBlur
in="composite1"
stdDeviation="0"
result="blur"
id="feGaussianBlur4545" />
<feOffset
dx="0"
dy="2"
result="offset"
id="feOffset4547" />
<feComposite
in="offset"
in2="SourceGraphic"
operator="atop"
result="composite2"
id="feComposite4549" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4779">
<feFlood
flood-opacity="0.196078"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4781" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="out"
result="composite1"
id="feComposite4783" />
<feGaussianBlur
in="composite1"
stdDeviation="0"
result="blur"
id="feGaussianBlur4785" />
<feOffset
dx="0"
dy="-2"
result="offset"
id="feOffset4787" />
<feComposite
in="offset"
in2="SourceGraphic"
operator="atop"
result="composite2"
id="feComposite4789" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4791">
<feFlood
flood-opacity="0.196078"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4793" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="out"
result="composite1"
id="feComposite4795" />
<feGaussianBlur
in="composite1"
stdDeviation="0"
result="blur"
id="feGaussianBlur4797" />
<feOffset
dx="0"
dy="-2"
result="offset"
id="feOffset4799" />
<feComposite
in="offset"
in2="SourceGraphic"
operator="atop"
result="composite2"
id="feComposite4801" />
</filter>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="4.1822917"
inkscape:cx="39.691158"
inkscape:cy="95.079953"
inkscape:document-units="px"
inkscape:current-layer="layer6"
showgrid="false"
units="px"
inkscape:window-width="1680"
inkscape:window-height="1005"
inkscape:window-x="0"
inkscape:window-y="1"
inkscape:window-maximized="1" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
style="display:inline"
inkscape:label="Colors copy"
id="g16151"
inkscape:groupmode="layer">
<path
style="opacity:1;fill:#fc5d3e;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4779)"
d="M 14.101562,64 A 87.999993,87.924608 0 0 0 8,95.923828 C 7.98792,140.37931 26.484895,166.06204 9.8632812,175.74609 -2.8697567,183.16463 53.015901,184.00441 80,183.99414 L 80,64 14.101562,64 Z"
id="rect16153"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#45ae49;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4791)"
d="m 80,64 0,119.99414 c 8.240743,-0.003 14.054688,-0.0801 14.054688,-0.0801 L 96,183.84961 A 87.999993,87.924608 0 0 0 184,95.923828 87.999993,87.924608 0 0 0 177.99023,64 L 80,64 Z"
id="rect16157"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#2d526b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4539)"
d="M 96,8 A 87.999993,87.924608 0 0 0 9.5195312,80 L 182.49805,80 A 87.999993,87.924608 0 0 0 96,8 Z"
id="rect16159"
inkscape:connector-curvature="0" />
</g>
<g
inkscape:label="Letter t"
id="g16167"
inkscape:groupmode="layer"
style="display:inline">
<g
inkscape:groupmode="layer"
id="layer6"
inkscape:label="Shadow"
style="display:inline;opacity:0.5">
<path
style="opacity:1;fill:url(#linearGradient16249);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 88.001953,40.974609 79.402344,153.7832 119.19336,180.72266 c 34.16846,-9.36841 59.38095,-38.29738 63.9707,-73.40039 z"
id="rect16225"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
</g>
<text
sodipodi:linespacing="125%"
id="text16169"
y="153.19533"
x="53.125"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:160.00001526px;line-height:125%;font-family:Arial;-inkscape-font-specification:'Arial, Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
xml:space="preserve"><tspan
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:160.00001526px;line-height:125%;font-family:Arial;-inkscape-font-specification:'Arial, Bold';text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1"
y="153.19533"
x="53.125"
id="tspan16171"
sodipodi:role="line">t</tspan></text>
</g>
<g
inkscape:label="Bubble"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-860.36222)"
style="display:none">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter16588)"
d="M 0,0 0,192 192,192 192,0 0,0 Z m 96,8 a 87.999998,87.924613 0 0 1 88,87.923828 87.999998,87.924613 0 0 1 -88,87.925782 l -1.945312,0.0644 c 0,0 -100.813021,1.51613 -84.1914068,-8.16797 C 26.484896,166.06199 7.98792,140.37931 8,95.923828 A 87.999998,87.924613 0 0 1 96,8 Z"
transform="translate(0,860.36222)"
id="rect16110"
inkscape:connector-curvature="0"
inkscape:export-xdpi="120.00001"
inkscape:export-ydpi="120.00001" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

-48
View File
@@ -1,48 +0,0 @@
<!-- DEVELOPMENT version of the web app. -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tinode</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="application-name" content="TinodeWeb">
<meta name="description" content="Tinode instant messenger">
<meta name="application-url" content="https://api.tinode.co/">
<!-- Browser styling for Chrome, FF, Opera -->
<meta name="theme-color" content="#3949AB">
<!-- Ask Google not to offer page translation -->
<meta name="google" value="notranslate">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://api.tinode.co/" />
<!-- Google's Roboto font -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<!-- Google's material design icons -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<!-- Styles for local elements -->
<link rel="stylesheet" href="css/base.css" />
<!-- ReactJs scripts -->
<script crossorigin="anonymous" src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<!-- React-Intl -->
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15/prop-types.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-intl@2/dist/react-intl.js"></script>
<!-- Firebase scripts -->
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-alertbox"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.
</div>
</div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
<script src="umd/index.dev.js"></script>
</body>
</html>
-35
View File
@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Tinode</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="application-name" content="TinodeWeb">
<meta name="description" content="Tinode instant messaging">
<meta name="application-url" content="https://api.tinode.co/">
<meta name="theme-color" content="#3949AB">
<meta name="google" value="notranslate">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://api.tinode.co/" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="css/base.min.css" />
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.4/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.4/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15.7.2/prop-types.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-intl@2.8.0/dist/react-intl.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-alertbox"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.</div></div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
<script src="umd/index.prod.js"></script>
</body>
</html>
-26
View File
@@ -1,26 +0,0 @@
{
"short_name": "Tinode chat",
"name": "Tinode Web chat",
"icons": [
{
"src":"img/logo96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src":"img/logo192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "img/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"start_url": "/",
"background_color": "silver",
"theme_color": "#3949AB",
"display": "standalone",
"gcm_sender_id": "103953800507"
}
-6527
View File
File diff suppressed because it is too large Load Diff
-86
View File
@@ -1,86 +0,0 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.15.15-rc2",
"devDependencies": {
"@babel/core": "^7.3.4",
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.5",
"babel-plugin-react-intl": "^3.0.1",
"browserslist": "^4.4.1",
"cssnano": "^4.1.10",
"postcss": "^7.0.17",
"uglifyjs-webpack-plugin": "^2.1.2",
"webpack": "^4.29.6",
"webpack-cli": "^3.2.3"
},
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
},
"scripts": {
"build": "npm run vers && npm run build:prod && npm run build:dev && npm run build:css && npm run build:i18n",
"build:css": "postcss css/base.css > css/base.min.css",
"build:dev": "webpack --mode development --output ./umd/index.dev.js",
"build:prod": "webpack --mode production --output ./umd/index.prod.js",
"build:i18n": "node scripts/flatten-messages.js",
"vers": "node scripts/gen-version.js"
},
"files": [
"src/index.js",
"src/config.js",
"src/views/*.jsx",
"src/widgets/*.jsx",
"src/lib/*.js",
"src/i18n/*.json",
"umd/index.prod.js",
"umd/index.prod.js.map",
"umd/index.dev.js",
"umd/index.dev.js.map",
"audio/msg.mp3",
"css/base.css",
"css/base.min.css",
"img/ignasi_pattern_s.png",
"img/logo.svg",
"img/logo32x32.png",
"img/logo32x32a.png",
"img/logo96.png",
"img/logo192.png",
"img/badge96.png",
"img/broken_image.png",
"index-dev.html",
"index.html",
"manifest.json",
"service-worker.js",
"firebase-init.js",
"web-desktop-2.png",
"web-mob-chat-1.png",
"web-mob-contacts-1.png",
"web-mob-info-1.png",
"web-mob-new-chat-1.png"
],
"keywords": [
"chat",
"instant-messenger",
"message",
"messenger",
"react",
"web"
],
"main": "umd/index.prod.js",
"bugs": {
"url": "https://github.com/tinode/example-react-js/issues"
},
"homepage": "https://github.com/tinode/chat",
"email": "info@tinode.co",
"author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0",
"dependencies": {
"firebase": "^5.8.6",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-intl": "^2.8.0",
"tinode-sdk": "^0.15.14"
}
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
plugins: [
require('cssnano')({
preset: 'default',
}),
],
};
-97
View File
@@ -1,97 +0,0 @@
// 1.Flattens messages extracted via the React Intl Babel plugin. An error will be
// thrown if there are messages with the same `id`. The result is a file containing
// `id: message` pairs in "en" locale.
//
// 2. Takes all translations and groups them into a single file.
var fs = require('fs');
var path = require('path');
var globSync = require('glob').sync;
var mkdirpSync = require('mkdirp').sync;
const DEFAULT_TRANSLATION = 'en.json';
const MESSAGES_PATTERN = './src/i18n/src/**/*.json';
const LANG_DIR = './src/i18n/';
const LANG_PATTERN = './src/i18n/*.json';
const FINAL_FILE = './src/messages.json';
const MISSING_MARKER = ':!![MISSING]!!:';
const OBSOLETE_MARKER = ':!![OBSOLETE]!!:';
// The react-intl-babel-plugin saves extracted messages
// into files, one fine per component. Rewrite them into
// a single file en.json.
let messages = globSync(MESSAGES_PATTERN)
.map((filename) => fs.readFileSync(filename, 'utf8'))
.map((file) => JSON.parse(file))
.reduce((collection, descriptors) => {
descriptors.forEach(({id, defaultMessage}) => {
if (collection.hasOwnProperty(id)) {
throw new Error(`Duplicate message id: ${id}`);
}
collection[id] = defaultMessage;
});
return collection;
}, {});
let filename = path.join(LANG_DIR, DEFAULT_TRANSLATION);
mkdirpSync(path.dirname(filename));
fs.writeFileSync(filename, JSON.stringify(messages, null, 2)+"\n");
// Find messages present in en.json but missing in translations and vice versa.
// Mark missing or obsolete messages as such in translation files.
globSync(LANG_PATTERN)
.map((filename) => {
const parts = filename.split('/');
if (parts[parts.length-1] != DEFAULT_TRANSLATION) {
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
// Find missing messages.
Object.entries(messages).forEach((ent) => {
if (!translated.hasOwnProperty(ent[0])) {
translated[ent[0]] = MISSING_MARKER + ent[1];
}
});
// Find no longer needed messages.
Object.entries(translated).forEach((ent) => {
if (!messages.hasOwnProperty(ent[0])) {
let val = translated[ent[0]];
if (val.indexOf(MISSING_MARKER) == 0) {
val = val.substring(0, MISSING_MARKER.length);
}
if (val.indexOf(OBSOLETE_MARKER) != 0) {
val = OBSOLETE_MARKER + val;
}
translated[ent[0]] = val;
}
});
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
}
});
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
// while removing messages marked as missing or obsolete.
// Read all translations.
messages = globSync(LANG_PATTERN)
.map((filename) => {
const parts = filename.split('/');
const locale = parts[parts.length - 1].split('.json')[0];
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
// Strip messages prefixed with MISSING or OBSOLETE markers.
for (let id in translated) {
if (translated[id].indexOf(OBSOLETE_MARKER) == 0 ||
translated[id].indexOf(MISSING_MARKER) == 0) {
delete translated[id];
}
}
return { [locale]: translated };
})
.reduce((collection, descriptors) => {
return { ...collection, ...descriptors };
}, {});
// Write file with all translations combined.
mkdirpSync(path.dirname(FINAL_FILE));
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages, null, 2)+"\n");
-13
View File
@@ -1,13 +0,0 @@
var fs = require('fs');
var version = require('../package.json').version;
fs.writeFile('./src/version.js',
'// This is a generated file. Don\'t edit.\n\n'+
'export const PACKAGE_VERSION = "'+version+'";\n',
{ flag: 'w' },
function(err) {
if (err) {
return console.log(err);
}
}
);
-88
View File
@@ -1,88 +0,0 @@
// Must be located at the root.
importScripts('https://unpkg.com/firebase@5.8.1/firebase-app.js');
importScripts('https://unpkg.com/firebase@5.8.1/firebase-messaging.js');
importScripts('firebase-init.js');
firebase.initializeApp(FIREBASE_INIT);
// This method shows the notifications.
firebase.messaging().setBackgroundMessageHandler(function(payload) {
let title = payload.data.title || "New message";
let options = {
body: payload.data.content || "",
icon: '/img/logo96.png',
badge: '/img/badge96.png',
tag: payload.data.topic || undefined,
data: {
topic: payload.data.topic
}
};
return self.registration.showNotification(title, options);
});
// Update service worker immediately for both the current client
// and all other active clients.
self.addEventListener('install', event => {
self.skipWaiting();
});
// This code handles a click on notification: takes
// the user to the browser tab with the chat or opens a new tab.
self.addEventListener('notificationclick', event => {
const notification = event.notification;
notification.close();
const urlHash = '#/' + notification.data.topic;
event.waitUntil(self.clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then((windowClients) => {
let anyClient = null;
for (let i = 0; i < windowClients.length; i++) {
const url = new URL(windowClients[i].url);
if (url.hash.includes(notification.data.topic)) {
// Found the Tinode tab with the right topic open.
return windowClients[i].focus();
} else {
// This will be the least recently used tab.
anyClient = windowClients[i];
}
}
// Found tab with Tinode on a different topic,
// navigate to the right topic.
if (anyClient) {
const url = new URL(anyClient.url);
url.hash = urlHash;
return anyClient.focus().then(thisClient => {
return thisClient.navigate(url);
});
}
// Did not find a Tinode browser tab. Open one.
const url = new URL(self.location.origin);
url.hash = urlHash;
return clients.openWindow(url);
}));
});
// This is needed for 'Add to Home Screen'.
self.addEventListener('fetch', event => {
// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=823392
if (event.request.cache == 'only-if-cached' && event.request.mode != 'same-origin') {
return;
}
event.respondWith(
// Try to find response in cache.
caches.match(event.request)
.then((resp) => {
// If response is found in cache, return it. Otherwise fetch.
return resp || fetch(event.request);
})
.catch((err) => {
// Something went wrong.
console.log("Service worker Fetch: ", err);
})
);
});
-65
View File
@@ -1,65 +0,0 @@
import { PACKAGE_VERSION } from './version.js';
// Name of this application, used in the User-Agent.
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.15');
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
// The array of possible hosts to connect to.
export const KNOWN_HOSTS = {hosted: 'api.tinode.co', local: 'localhost:6060'};
// Default host name and port to connect to.
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
// Minimum time between two keypress notifications, milliseconds.
export const KEYPRESS_DELAY = 3*1000;
// Delay before sending a {note} for reciving a message, milliseconds.
export const RECEIVED_DELAY = 500;
// Delay before sending a read notification, milliseconds.
export const READ_DELAY = 1000;
// The shortest allowed tag length. Matches the value on the server.
export const MIN_TAG_LENGTH = 4;
// Access mode for subscriptions initiated by the current user.
export const DEFAULT_ACCESS_MODE = 'JRWPS';
// Access mode for no access.
export const NO_ACCESS_MODE = 'N';
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
// in @media (max-size: 640px) in base.css
export const MEDIA_BREAKPOINT = 640;
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
export const REM_SIZE = 13;
// Size of the avatar image: When an avatar image is uploaded, it's resized to
// a square of this size.
export const AVATAR_SIZE = 128;
// Size of the broken_image shown in MessagesView
export const BROKEN_IMAGE_SIZE = 32;
// Number of chat messages to fetch in one call.
export const MESSAGES_PAGE = 24;
// Maximum in-band (included directly into the message) attachment size which fits into
// a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead.
// This is size of an object *before* base64 encoding is applied.
// Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in server config.
// MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size - overhead);
export const MAX_INBAND_ATTACHMENT_SIZE = 195840;
// Absolute maximum attachment size to be used with the server = 8MB. Increase to
// something like 100MB in production.
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
// Maximum allowed linear dimension of an inline image in pixels. You may want
// to adjust it to 1600 or 2400 for production.
export const MAX_IMAGE_DIM = 768;
// Maximum number of online users to be shown in a topic title bar. Others will be
// hidden under "+X more"
export const MAX_ONLINE_IN_TOPIC = 4;
-137
View File
@@ -1,137 +0,0 @@
{
"archived_contacts": "Archived contacts ({count})",
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Full name, e.g. John Doe",
"email_prompt": "Email, e.g. jdoe@example.com",
"button_sign_up": "Sign up",
"label_your_name": "Your name",
"label_password": "Password",
"password_unchanged_prompt": "Unchanged",
"label_message_sound": "Message sound:",
"label_push_notifications": "Notification alerts:",
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
"title_tag_manager": "Tags (user discovery)",
"label_user_contacts": "Contacts:",
"label_user_id": "Address:",
"label_default_access_mode": "Default access mode:",
"button_logout": "Logout",
"requested_permissions": "Requested",
"granted_permissions": "Granted",
"menu_item_edit_permissions": "Edit permissions",
"label_other_user": "Other",
"title_info": "Info",
"label_topic_name": "Name",
"label_private": "Private comment",
"private_editing_placeholder": "Visible to you only",
"label_muting_topic": "Muted:",
"action_more": "More",
"label_your_permissions": "Your permissions:",
"label_permissions": "Permissions:",
"label_you": "You:",
"label_default_access": "Default access mode:",
"label_group_members": "Group members:",
"button_add_members": "Add members",
"button_leave": "Leave",
"group_has_no_members": "No members",
"action_leave_chat": "Leave",
"login_prompt": "Login",
"password_prompt": "Password",
"stay_logged_in": "Stay logged in",
"forgot_password_link": "Forgot password?",
"button_sign_in": "Sign in",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "online now",
"last_seen_timestamp": "Last seen",
"title_not_found": "Not found",
"unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.",
"enable_peers_messaging": "Enable",
"tabtitle_find_user": "find",
"tabtitle_new_group": "new group",
"tabtitle_group_by_id": "by id",
"search_for_contacts": "Use search to find contacts",
"new_password_placeholder": "Enter new password",
"label_reset_password": "Send a password reset email:",
"credential_email_prompt": "Your registration email",
"button_reset": "Reset",
"button_send_request": "Send request",
"label_server_to_use": "Server to use:",
"label_wire_transport": "Wire transport:",
"button_update": "Update",
"sidepanel_title_login": "Sign In",
"sidepanel_title_register": "Create Account",
"sidepanel_title_settings": "Settings",
"sidepanel_title_edit_account": "Edit Account",
"sidepanel_title_newtpk": "Start New Chat",
"sidepanel_title_cred": "Confirm Credentials",
"sidepanel_title_reset": "Reset Password",
"sidepanel_title_archive": "Archived Chats",
"update_available": "Update available. <a href=\"\">Reload</a>.",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
"reconnect_now": "Try now",
"phone_dative": "phone",
"email_dative": "email",
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
"numeric_confirmation_code_prompt": "Numbers only",
"button_confirm": "Confirm",
"save_attachment": "save",
"invalid_content": "invalid content",
"user_not_found": "Not found",
"badge_you": "you",
"badge_owner": "owner",
"menu_item_info": "Info",
"menu_item_clear_messages": "Clear messages",
"menu_item_clear_messages_for_all": "Clear for All",
"menu_item_delete": "Delete",
"menu_item_delete_for_all": "Delete for All",
"menu_item_mute": "Mute",
"menu_item_unmute": "Unmute",
"menu_item_delete_topic": "Delete",
"menu_item_unblock": "Unblock",
"menu_item_block": "Block",
"menu_item_member_delete": "Remove",
"menu_item_archive_topic": "Archive",
"action_cancel": "cancel",
"upload_finishing": "finishing...",
"no_contacts": "You have no contacts :-(",
"contacts_not_found_short": "No contacts match '{query}'",
"title_group_members": "Group Members",
"title_all_contacts": "All Contacts",
"button_ok": "OK",
"button_cancel": "Cancel",
"more_online_members": "+{overflow} more",
"download_action": "download",
"label_file_name": "File name:",
"label_content_type": "Content type:",
"label_size": "Size:",
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
"chat_invitation_accept": "Accept",
"chat_invitation_ignore": "Ignore",
"chat_invitation_block": "Block",
"error_invalid_id": "Invalid ID",
"group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group",
"button_create": "Create",
"permission_join": "Join ({val})",
"permission_read": "Read ({val})",
"permission_write": "Write ({val})",
"permission_pres": "Get notified ({val})",
"permission_admin": "Approve ({val})",
"permission_share": "Share ({val})",
"permission_delete": "Delete ({val})",
"permission_owner": "Owner ({val})",
"title_permissions": "Permissions",
"message_sending": "sending...",
"message_sending_failed": "failed",
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
"messaging_disabled_prompt": "Messaging disabled",
"new_message_prompt": "New message",
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
"cannot_initiate_file_upload": "Cannot initiate file upload.",
"tags_not_found": "No tags defined. Add some.",
"tags_editor_no_tags": "Add some tags",
"title_manage_tags": "Manage tags"
}
-137
View File
@@ -1,137 +0,0 @@
{
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Полное имя, напр. Иван Петров",
"email_prompt": "Email, напр. ivan@example.com",
"button_sign_up": "Создать счет",
"label_your_name": "Ваше имя",
"label_password": "Пароль",
"password_unchanged_prompt": "Не изменен",
"label_user_id": "Адрес:",
"label_default_access_mode": "Доступ по умолчанию:",
"label_message_sound": "Звук нового сообщения:",
"label_push_notifications": "Уведомления:",
"label_push_notifications_disabled": "Уведомления (требуют HTTPS):",
"title_tag_manager": "Таги для поиска",
"button_logout": "Выйти",
"login_prompt": "Логин",
"password_prompt": "Пароль",
"stay_logged_in": "Запомнить",
"forgot_password_link": "Напомнить пароль",
"button_sign_in": "Войти",
"label_client": "Клиент:",
"label_server": "Сервер:",
"online_now": "онлайн",
"last_seen_timestamp": "Был активен",
"title_not_found": "Не найден",
"unnamed_topic": "Без названия",
"messages_not_readable": "нет доступа к сообщениям",
"tabtitle_find_user": "найти",
"tabtitle_new_group": "создать",
"tabtitle_group_by_id": "по id",
"label_server_to_use": "Использовать сервер:",
"label_wire_transport": "Соединение:",
"button_update": "Применить",
"sidepanel_title_login": "Авторизация",
"sidepanel_title_register": "Зарегистрироваться",
"sidepanel_title_settings": "Настройки",
"sidepanel_title_edit_account": "Редактировать счет",
"sidepanel_title_newtpk": "Новый чат",
"sidepanel_title_cred": "Подтвердить",
"sidepanel_title_reset": "Сменить пароль",
"tags_not_found": "Тагов нет. Добавьте",
"tags_editor_no_tags": "Добавьте таги",
"title_manage_tags": "Редактор тагов",
"message_sending": "в пути...",
"message_sending_failed": "ошибка",
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "Отправка недоступна",
"new_message_prompt": "Новое сообщение",
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
"search_for_contacts": "Поиск контактов",
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
"numeric_confirmation_code_prompt": "Только цифры",
"button_confirm": "Подтвердить",
"button_ok": "OK",
"button_cancel": "Отменить",
"invalid_content": "сообщение не читается",
"label_file_name": "Имя файла:",
"label_content_type": "Тип:",
"label_size": "Размер:",
"phone_dative": "телефону",
"email_dative": "емейлу",
"title_group_members": "Участники",
"download_action": "скачать",
"permission_join": "Подписываться ({val})",
"permission_read": "Читать ({val})",
"permission_write": "Писать ({val})",
"permission_pres": "Уведомлять ({val})",
"permission_admin": "Подтверждать ({val})",
"permission_share": "Приглашать ({val})",
"permission_delete": "Удалять ({val})",
"permission_owner": "Владелец ({val})",
"title_permissions": "Права доступа",
"requested_permissions": "Требуются",
"granted_permissions": "Получены",
"menu_item_edit_permissions": "Права доступа",
"label_other_user": "Второй",
"label_topic_name": "Название",
"label_private": "Комментарий",
"private_editing_placeholder": "Виден только вам",
"label_muting_topic": "Без уведомлений",
"action_more": "Ещё",
"label_your_permissions": "Ваши права доступа:",
"label_permissions": "Права доступа:",
"label_you": "Вы:",
"label_default_access": "Права по умолчанию:",
"label_group_members": "Участники чата:",
"button_add_members": "Добавить",
"button_leave": "Отписаться",
"group_has_no_members": "Нет участников",
"action_leave_chat": "Уйти из чата",
"menu_item_info": "Информация",
"menu_item_clear_messages": "Удалить сообщения",
"menu_item_clear_messages_for_all": "Удалить для всех",
"menu_item_delete": "Удалить",
"menu_item_delete_for_all": "Удалить для всех",
"menu_item_mute": "Не уведомлять",
"menu_item_unmute": "Уведомлять",
"menu_item_delete_topic": "Удалить чат",
"menu_item_unblock": "Разблокировать",
"menu_item_block": "Заблокировать",
"menu_item_member_delete": "Отписать",
"title_info": "Подробности",
"new_password_placeholder": "Введите новый пароль",
"label_reset_password": "Отправить емейл для смены пароля:",
"credential_email_prompt": "Регистрационный емейл",
"button_reset": "Изменить",
"button_send_request": "Отправить",
"action_cancel": "отменить",
"upload_finishing": "завершение...",
"no_contacts": "Ничего нет :-(",
"contacts_not_found_short": "Нет контактов для запроса '{query}'",
"title_all_contacts": "Все контакты",
"error_invalid_id": "Неверный ID",
"group_user_id_prompt": "ID чата или пользователя",
"button_subscribe": "Подписаться",
"topic_name_editing_placeholder": "Название чата",
"button_create": "Создать",
"badge_you": "вы",
"badge_owner": "влад.",
"update_available": "Есть новая версия приложения. <a href=\"\">Обновить</a>.",
"user_not_found": "Не найден",
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
"reconnect_now": "Подключить сейчас.",
"save_attachment": "сохранить",
"menu_item_archive_topic": "В архив",
"archived_contacts": "Чаты в архиве ({count})",
"sidepanel_title_archive": "Архив чатов",
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
"chat_invitation_accept": "Принять",
"chat_invitation_ignore": "Игнорировать",
"chat_invitation_block": "Заблокировать",
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
"enable_peers_messaging": "Разблокировать.",
"more_online_members": "+еще {overflow}",
"label_user_contacts": "Конакты"
}
-137
View File
@@ -1,137 +0,0 @@
{
"archived_contacts": "已归档联系人 ({count})",
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
"full_name_prompt": "全名,例如张伟",
"email_prompt": "电子邮件,例如 zhang@example.com",
"button_sign_up": "注册",
"label_your_name": "你的姓名",
"label_password": "密码",
"password_unchanged_prompt": "未改变",
"label_user_id": "地址:",
"label_default_access_mode": "蓦然访问模式:",
"label_message_sound": "消息提示音:",
"label_push_notifications": "通知提醒:",
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
"title_tag_manager": "标签(用户发现)",
"button_logout": "登出",
"requested_permissions": "已请求",
"granted_permissions": "已授予",
"menu_item_edit_permissions": "编辑权限",
"label_other_user": "其他",
"title_info": "信息",
"label_topic_name": "名称",
"label_private": "私人评论",
"private_editing_placeholder": "仅自己可见",
"label_muting_topic": "已静音:",
"action_more": "更多",
"label_your_permissions": "你的权限:",
"label_permissions": "权限:",
"label_you": "你:",
"label_default_access": "默认权限模式:",
"label_group_members": "群组成员:",
"button_add_members": "添加成员",
"button_leave": "离开",
"group_has_no_members": "无成员",
"action_leave_chat": "离开",
"login_prompt": "登录",
"password_prompt": "密码",
"stay_logged_in": "保持登录",
"forgot_password_link": "忘记密码?",
"button_sign_in": "登录",
"label_client": "客户端:",
"label_server": "服务器:",
"online_now": "在线",
"last_seen_timestamp": "最后可见",
"title_not_found": "无法找到",
"unnamed_topic": "未命名",
"messages_not_readable": "无消息访问权限",
"peers_messaging_disabled": "成员间消息已禁用。",
"enable_peers_messaging": "启用",
"tabtitle_find_user": "搜索",
"tabtitle_new_group": "新群组",
"tabtitle_group_by_id": "通过 id",
"search_for_contacts": "使用搜索寻找联系人",
"new_password_placeholder": "输入新密码",
"label_reset_password": "发送密码重置邮件:",
"credential_email_prompt": "你的注册邮箱",
"button_reset": "重置",
"button_send_request": "发送请求",
"label_server_to_use": "使用的服务器:",
"label_wire_transport": "线路传输:",
"button_update": "更新",
"sidepanel_title_login": "登录",
"sidepanel_title_register": "创建账户",
"sidepanel_title_settings": "设置",
"sidepanel_title_edit_account": "编辑账户",
"sidepanel_title_newtpk": "开始新会话",
"sidepanel_title_cred": "确认凭据",
"sidepanel_title_reset": "重置密码",
"sidepanel_title_archive": "已存档会话",
"update_available": "更新可用。<a href=\"\">重新载入</a>.",
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
"reconnect_now": "立即尝试",
"phone_dative": "电话",
"email_dative": "电子邮件",
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
"numeric_confirmation_code_prompt": "仅数字",
"button_confirm": "确认",
"save_attachment": "保存",
"invalid_content": "无效内容",
"user_not_found": "未找到",
"badge_you": "你",
"badge_owner": "所有者",
"menu_item_info": "信息",
"menu_item_clear_messages": "清空消息",
"menu_item_clear_messages_for_all": "全部清除",
"menu_item_delete": "删除",
"menu_item_delete_for_all": "全部删除",
"menu_item_mute": "静音",
"menu_item_unmute": "取消静音",
"menu_item_delete_topic": "删除",
"menu_item_unblock": "取消屏蔽",
"menu_item_block": "屏蔽",
"menu_item_member_delete": "移除",
"menu_item_archive_topic": "归档",
"action_cancel": "取消",
"upload_finishing": "正在结束...",
"no_contacts": "你尚无联系人 :-(",
"contacts_not_found_short": "无联系人匹配 '{query}'",
"title_group_members": "群组成员",
"title_all_contacts": "全部联系人",
"button_ok": "好",
"button_cancel": "取消",
"download_action": "下载",
"label_file_name": "文件名:",
"label_content_type": "内容类型:",
"label_size": "大小:",
"chat_invitation": "你受邀开始新会话。你想怎么做?",
"chat_invitation_accept": "接受",
"chat_invitation_ignore": "忽略",
"chat_invitation_block": "屏蔽",
"error_invalid_id": "无效 ID",
"group_user_id_prompt": "群组或用户 ID",
"button_subscribe": "订阅",
"topic_name_editing_placeholder": "群组自由格式名称",
"button_create": "创建",
"permission_join": "加入 ({val})",
"permission_read": "读取 ({val})",
"permission_write": "写入 ({val})",
"permission_pres": "获取通知 ({val})",
"permission_admin": "批准 ({val})",
"permission_share": "分享 ({val})",
"permission_delete": "删除 ({val})",
"permission_owner": "所有者 ({val})",
"title_permissions": "权限",
"message_sending": "正在发送...",
"message_sending_failed": "发送失败",
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "消息已禁用",
"new_message_prompt": "新消息",
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
"cannot_initiate_file_upload": "无法初始化文件上传。",
"tags_not_found": "尚未定义标签。添加一些。",
"tags_editor_no_tags": "添加一些标签",
"title_manage_tags": "管理标签",
"more_online_members": "还有{overflow}个",
"label_user_contacts": "往来"
}
-34
View File
@@ -1,34 +0,0 @@
// Put all packages together.
// Used to generate umd/index.prod.js
import React from 'react';
import ReactDOM from 'react-dom';
import { IntlProvider, addLocaleData } from 'react-intl';
import ruData from 'react-intl/locale-data/ru';
addLocaleData(ruData);
import allMessages from './messages.json';
import TinodeWeb from './views/tinode-web.jsx';
import HashNavigation from './lib/navigation.js';
const {params} = HashNavigation.parseUrlHash(window.location.hash);
const language = (params && params.hl) ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator.userLanguage;
const baseLanguage = language.toLowerCase().split(/[-_]+/)[0];
// Try full locale, try locale without region code, fallback to 'en'
const messages =
allMessages[language] ||
allMessages[baseLanguage] ||
allMessages.en;
ReactDOM.render(
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
<TinodeWeb />
</IntlProvider>,
document.getElementById('mountPoint')
);
-219
View File
@@ -1,219 +0,0 @@
// File and image helper functions.
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
// Supported image MIME types and corresponding file extensions.
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
// Make a data URL from public.photo
export function makeImageUrl(photo) {
return (photo && photo.type && photo.data) ?
'data:image/' + photo.type + ';base64,' + photo.data : null;
}
// Calculate linear dimensions for scaling image down to fit under a certain size.
// Returns an object which contains destination sizes, source sizes, and offsets
// into source (when making square images).
export function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {
// Sanitize input
width = width | 0;
height = height | 0;
maxWidth = maxWidth | 0;
maxHeight = maxHeight | 0;
if (width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0) {
return null;
}
if (forceSquare) {
maxWidth = maxHeight = Math.min(maxWidth, maxHeight);
}
let scale = Math.min(
Math.min(width, maxWidth) / width,
Math.min(height, maxHeight) / height
);
let size = {
dstWidth: (width * scale) | 0,
dstHeight: (height * scale) | 0,
};
if (forceSquare) {
// Also calculate parameters for making the image square.
size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight);
size.srcWidth = size.srcHeight = Math.min(width, height);
size.xoffset = ((width - size.srcWidth) / 2) | 0;
size.yoffset = ((height - size.srcWidth) / 2) | 0;
} else {
size.xoffset = size.yoffset = 0;
size.srcWidth = width;
size.srcHeight = height;
}
return size;
}
// Ensure file's extension matches mime content type
export function fileNameForMime(fname, mime) {
var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
var ext = MIME_EXTENSIONS[idx];
var at = fname.lastIndexOf('.');
if (at >= 0) {
fname = fname.substring(0, at);
}
return fname + '.' + ext;
}
// Convert uploaded image into a base64-encoded string possibly scaling
// linear dimensions or constraining to a square.
export function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) {
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onerror = function(err) {
onError("Image format unrecognized");
}
img.onload = function() {
var dim = fitImageSize(this.width, this.height, width, height, forceSquare);
if (!dim) {
onError("Invalid image");
return;
}
var canvas = document.createElement('canvas');
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
var ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight);
var mime = (this.width != dim.dstWidth ||
this.height != dim.dstHeight ||
SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type;
var imageBits = canvas.toDataURL(mime);
var parts = imageBits.split(',');
// Get actual image type: 'data:image/png;base64,'
mime = getMimeType(parts[0]);
if (!mime) {
onError("Unsupported image format");
return;
}
// Ensure the image is not too large
var quality = 0.78;
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
mime = 'image/jpeg';
}
if (mime == 'image/jpeg') {
// Reduce size of the jpeg by reducing image quality
while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) {
imageBits = canvas.toDataURL(mime, quality);
quality *= 0.84;
}
}
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) +
" exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err");
return;
}
canvas = null;
onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime));
};
img.src = URL.createObjectURL(file);
}
// Convert uploaded image file to base64-encoded string without scaling/converting the image
export function imageFileToBase64(file, onSuccess, onError) {
var reader = new FileReader();
reader.addEventListener('load', function() {
var parts = reader.result.split(',');
var mime = getMimeType(parts[0]);
if (!mime) {
onError("Failed to process image file");
return;
}
// Get image size.
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = function() {
onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime));
}
img.onerror = function(err) {
onError("Image format not recognized");
}
img.src = URL.createObjectURL(file);
}, false);
reader.readAsDataURL(file);
}
export function fileToBase64(file, onSuccess, onError) {
var reader = new FileReader();
reader.addEventListener('load', function() {
onSuccess(file.type, reader.result.split(',')[1], file.name);
});
reader.readAsDataURL(file);
}
// File pasted from the clipboard. It's either an inline image or a file attachment.
// FIXME: handle large files out of band.
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
for (var i in items) {
var item = items[i];
if (item.kind === 'file') {
var file = item.getAsFile();
if (!file) {
console.log("Failed to get file object from pasted file item", item.kind, item.type);
continue;
}
if (file.type && file.type.split('/')[0] == 'image') {
// Handle inline image
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError);
} else {
imageFileToBase64(file, onImageSuccess, onError);
}
} else {
// Handle file attachment
fileToBase64(file, onAttachmentSuccess, onError)
}
// Indicate that the pasted data contains a file.
return true;
}
}
// No file found.
return false;
}
// Get mime type from data URL header.
export function getMimeType(header) {
var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
return (mime && mime.length > 1) ? mime[1] : null;
}
// Given length of a binary object in bytes, calculate the length after
// base64 encoding.
export function base64EncodedLen(n) {
return Math.floor((n + 2) / 3) * 4;
}
// Given length of a base64-encoded object, calculate decoded size of the
// pbject in bytes.
export function base64DecodedLen(n) {
return Math.floor(n / 4) * 3;
}
// Re-encode string to standard base64 encoding with padding.
// The string may be base64-URL encoded without the padding.
export function base64ReEncode(str) {
if (str) {
str = str.replace('-', '+').replace('_', '/');
try {
str = btoa(atob(str));
} catch(err) {
console.log("Failed to base64 re-encode string");
str = null;
}
}
return str;
}
-29
View File
@@ -1,29 +0,0 @@
import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
// Detect server address from the URL
export function detectServerAddress() {
let host = DEFAULT_HOST;
if (typeof window.location == 'object') {
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') {
host = KNOWN_HOSTS.local;
} else if (window.location.hostname) {
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
}
}
return host;
}
// Detect if the page is served over HTTPS.
export function isSecureConnection() {
if (typeof window.location == 'object') {
return window.location.protocol == 'https:';
}
return false;
}
export function isLocalHost() {
if (typeof window.location == 'object') {
return window.location.hostname == 'localhost';
}
return false;
}
-26
View File
@@ -1,26 +0,0 @@
// Helper functions for storing values in localStorage.
// By default localStorage can store only strings, not objects or other types.
export default class LocalStorageUtil {
// Replace old object with the new one.
static setObject(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
// Get stored object.
static getObject(key) {
const value = localStorage.getItem(key);
return value && JSON.parse(value);
}
// Partially or wholly update stored object.
static updateObject(key, value) {
const oldVal = this.getObject(key);
this.setObject(key, Object.assign(oldVal || {}, value));
}
// Just a wrapper.
static removeItem(key) {
localStorage.removeItem(key);
}
}
-68
View File
@@ -1,68 +0,0 @@
// Utility class for hash navigation.
// Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments.
export default class HashNavigation {
static parseUrlHash(hash) {
// Split path from args, path -> parts[0], args->path[1]
let parts = hash.split('?', 2);
let params = {};
let path = [];
if (parts[0]) {
path = parts[0].substr(1).split('/');
}
if (parts[1]) {
parts[1].split('&').forEach(function(part) {
let item = part.split('=');
if (item[0]) {
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
}
});
}
return {path: path, params: params};
}
static navigateTo(url) {
window.location.hash = url;
}
static composeUrlHash(path, params) {
var url = path.join('/');
var args = [];
for (var key in params) {
if (params.hasOwnProperty(key)) {
args.push(key + '=' + params[key]);
}
}
if (args.length > 0) {
url += '?' + args.join('&');
}
return url;
}
static addUrlParam(hash, key, value) {
var parsed = this.parseUrlHash(hash);
parsed.params[key] = value;
return this.composeUrlHash(parsed.path, parsed.params);
}
static removeUrlParam(hash, key) {
var parsed = this.parseUrlHash(hash);
delete parsed.params[key];
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlSidePanel(hash, sidepanel) {
var parsed = this.parseUrlHash(hash);
parsed.path[0] = sidepanel;
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlTopic(hash, topic) {
var parsed = this.parseUrlHash(hash);
parsed.path[1] = topic;
// Close InfoView on topic change.
delete parsed.params.info;
return this.composeUrlHash(parsed.path, parsed.params);
}
}
-48
View File
@@ -1,48 +0,0 @@
// Short representation of time in the past.
export function shortDateFormat(then, locale) {
locale = locale || window.navigator.userLanguage || window.navigator.language;
const now = new Date();
if (then.getFullYear() == now.getFullYear()) {
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
} else {
return then.toLocaleDateString(locale,
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
}
return then.toLocaleDateString(locale,
{hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
export function secondsToTime(seconds) {
const min = Math.floor(seconds / 60);
let sec = seconds % 60;
sec = sec < 10 ? `0${sec}` : sec;
return `${min}:${sec}`;
}
// Convert a number of bytes to human-readable format.
export function bytesToHumanSize(bytes) {
if (!bytes || bytes == 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1);
const count = bytes / Math.pow(1024, bucket);
const round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
return count.toFixed(round) + ' ' + sizes[bucket];
}
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
// as Java's String#hash().
export function stringHash(value) {
let hash = 0;
value = '' + value;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash) + value.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
-82
View File
@@ -1,82 +0,0 @@
// Odds and ends
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
const oldIcon = document.getElementById('shortcut-icon');
const head = document.head || document.getElementsByTagName('head')[0];
const newIcon = document.createElement('link');
newIcon.type = 'image/png';
newIcon.id = 'shortcut-icon';
newIcon.rel = 'shortcut icon';
newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png';
if (oldIcon) {
head.removeChild(oldIcon);
}
head.appendChild(newIcon);
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
}
// Create VCard which represents topic 'public' info
export function vcard(fn, imageDataUrl) {
let card = null;
if ((fn && fn.trim()) || imageDataUrl) {
card = {};
if (fn) {
card.fn = fn.trim();
}
if (imageDataUrl) {
const dataStart = imageDataUrl.indexOf(',');
card.photo = {
data: imageDataUrl.substring(dataStart+1),
type: 'jpg'
};
}
}
return card;
}
// Deep-shallow compare two arrays: shallow compare each element.
export function arrayEqual(a, b) {
if (a === b) {
return true;
}
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
// Compare lengths first.
if (a.length != b.length) {
return false;
}
// Order of elements is ignored.
a.sort();
b.sort();
for (let i = 0, l = a.length; i < l; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// Checks (loosely) if the given string is a phone. If so, returns the phone number in a format
// as close to E.164 as possible.
export function asPhone(val) {
val = val.trim();
if (/^\s*(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?\s*$/.test(val)) {
return val.replace(/[- ().]*/, '');
}
return null;
}
// Checks (loosely) if the given string is an email. If so returns the email.
export function asEmail(val) {
val = val.trim();
if (/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(val)) {
return val;
}
return null;
}
-412
View File
@@ -1,412 +0,0 @@
{
"en": {
"archived_contacts": "Archived contacts ({count})",
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Full name, e.g. John Doe",
"email_prompt": "Email, e.g. jdoe@example.com",
"button_sign_up": "Sign up",
"label_your_name": "Your name",
"label_password": "Password",
"password_unchanged_prompt": "Unchanged",
"label_message_sound": "Message sound:",
"label_push_notifications": "Notification alerts:",
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
"title_tag_manager": "Tags (user discovery)",
"label_user_contacts": "Contacts:",
"label_user_id": "Address:",
"label_default_access_mode": "Default access mode:",
"button_logout": "Logout",
"requested_permissions": "Requested",
"granted_permissions": "Granted",
"menu_item_edit_permissions": "Edit permissions",
"label_other_user": "Other",
"title_info": "Info",
"label_topic_name": "Name",
"label_private": "Private comment",
"private_editing_placeholder": "Visible to you only",
"label_muting_topic": "Muted:",
"action_more": "More",
"label_your_permissions": "Your permissions:",
"label_permissions": "Permissions:",
"label_you": "You:",
"label_default_access": "Default access mode:",
"label_group_members": "Group members:",
"button_add_members": "Add members",
"button_leave": "Leave",
"group_has_no_members": "No members",
"action_leave_chat": "Leave",
"login_prompt": "Login",
"password_prompt": "Password",
"stay_logged_in": "Stay logged in",
"forgot_password_link": "Forgot password?",
"button_sign_in": "Sign in",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "online now",
"last_seen_timestamp": "Last seen",
"title_not_found": "Not found",
"unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.",
"enable_peers_messaging": "Enable",
"tabtitle_find_user": "find",
"tabtitle_new_group": "new group",
"tabtitle_group_by_id": "by id",
"search_for_contacts": "Use search to find contacts",
"new_password_placeholder": "Enter new password",
"label_reset_password": "Send a password reset email:",
"credential_email_prompt": "Your registration email",
"button_reset": "Reset",
"button_send_request": "Send request",
"label_server_to_use": "Server to use:",
"label_wire_transport": "Wire transport:",
"button_update": "Update",
"sidepanel_title_login": "Sign In",
"sidepanel_title_register": "Create Account",
"sidepanel_title_settings": "Settings",
"sidepanel_title_edit_account": "Edit Account",
"sidepanel_title_newtpk": "Start New Chat",
"sidepanel_title_cred": "Confirm Credentials",
"sidepanel_title_reset": "Reset Password",
"sidepanel_title_archive": "Archived Chats",
"update_available": "Update available. <a href=\"\">Reload</a>.",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
"reconnect_now": "Try now",
"phone_dative": "phone",
"email_dative": "email",
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
"numeric_confirmation_code_prompt": "Numbers only",
"button_confirm": "Confirm",
"save_attachment": "save",
"invalid_content": "invalid content",
"user_not_found": "Not found",
"badge_you": "you",
"badge_owner": "owner",
"menu_item_info": "Info",
"menu_item_clear_messages": "Clear messages",
"menu_item_clear_messages_for_all": "Clear for All",
"menu_item_delete": "Delete",
"menu_item_delete_for_all": "Delete for All",
"menu_item_mute": "Mute",
"menu_item_unmute": "Unmute",
"menu_item_delete_topic": "Delete",
"menu_item_unblock": "Unblock",
"menu_item_block": "Block",
"menu_item_member_delete": "Remove",
"menu_item_archive_topic": "Archive",
"action_cancel": "cancel",
"upload_finishing": "finishing...",
"no_contacts": "You have no contacts :-(",
"contacts_not_found_short": "No contacts match '{query}'",
"title_group_members": "Group Members",
"title_all_contacts": "All Contacts",
"button_ok": "OK",
"button_cancel": "Cancel",
"more_online_members": "+{overflow} more",
"download_action": "download",
"label_file_name": "File name:",
"label_content_type": "Content type:",
"label_size": "Size:",
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
"chat_invitation_accept": "Accept",
"chat_invitation_ignore": "Ignore",
"chat_invitation_block": "Block",
"error_invalid_id": "Invalid ID",
"group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group",
"button_create": "Create",
"permission_join": "Join ({val})",
"permission_read": "Read ({val})",
"permission_write": "Write ({val})",
"permission_pres": "Get notified ({val})",
"permission_admin": "Approve ({val})",
"permission_share": "Share ({val})",
"permission_delete": "Delete ({val})",
"permission_owner": "Owner ({val})",
"title_permissions": "Permissions",
"message_sending": "sending...",
"message_sending_failed": "failed",
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
"messaging_disabled_prompt": "Messaging disabled",
"new_message_prompt": "New message",
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
"cannot_initiate_file_upload": "Cannot initiate file upload.",
"tags_not_found": "No tags defined. Add some.",
"tags_editor_no_tags": "Add some tags",
"title_manage_tags": "Manage tags"
},
"ru": {
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Полное имя, напр. Иван Петров",
"email_prompt": "Email, напр. ivan@example.com",
"button_sign_up": "Создать счет",
"label_your_name": "Ваше имя",
"label_password": "Пароль",
"password_unchanged_prompt": "Не изменен",
"label_user_id": "Адрес:",
"label_default_access_mode": "Доступ по умолчанию:",
"label_message_sound": "Звук нового сообщения:",
"label_push_notifications": "Уведомления:",
"label_push_notifications_disabled": "Уведомления (требуют HTTPS):",
"title_tag_manager": "Таги для поиска",
"button_logout": "Выйти",
"login_prompt": "Логин",
"password_prompt": "Пароль",
"stay_logged_in": "Запомнить",
"forgot_password_link": "Напомнить пароль",
"button_sign_in": "Войти",
"label_client": "Клиент:",
"label_server": "Сервер:",
"online_now": "онлайн",
"last_seen_timestamp": "Был активен",
"title_not_found": "Не найден",
"unnamed_topic": "Без названия",
"messages_not_readable": "нет доступа к сообщениям",
"tabtitle_find_user": "найти",
"tabtitle_new_group": "создать",
"tabtitle_group_by_id": "по id",
"label_server_to_use": "Использовать сервер:",
"label_wire_transport": "Соединение:",
"button_update": "Применить",
"sidepanel_title_login": "Авторизация",
"sidepanel_title_register": "Зарегистрироваться",
"sidepanel_title_settings": "Настройки",
"sidepanel_title_edit_account": "Редактировать счет",
"sidepanel_title_newtpk": "Новый чат",
"sidepanel_title_cred": "Подтвердить",
"sidepanel_title_reset": "Сменить пароль",
"tags_not_found": "Тагов нет. Добавьте",
"tags_editor_no_tags": "Добавьте таги",
"title_manage_tags": "Редактор тагов",
"message_sending": "в пути...",
"message_sending_failed": "ошибка",
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "Отправка недоступна",
"new_message_prompt": "Новое сообщение",
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
"search_for_contacts": "Поиск контактов",
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
"numeric_confirmation_code_prompt": "Только цифры",
"button_confirm": "Подтвердить",
"button_ok": "OK",
"button_cancel": "Отменить",
"invalid_content": "сообщение не читается",
"label_file_name": "Имя файла:",
"label_content_type": "Тип:",
"label_size": "Размер:",
"phone_dative": "телефону",
"email_dative": "емейлу",
"title_group_members": "Участники",
"download_action": "скачать",
"permission_join": "Подписываться ({val})",
"permission_read": "Читать ({val})",
"permission_write": "Писать ({val})",
"permission_pres": "Уведомлять ({val})",
"permission_admin": "Подтверждать ({val})",
"permission_share": "Приглашать ({val})",
"permission_delete": "Удалять ({val})",
"permission_owner": "Владелец ({val})",
"title_permissions": "Права доступа",
"requested_permissions": "Требуются",
"granted_permissions": "Получены",
"menu_item_edit_permissions": "Права доступа",
"label_other_user": "Второй",
"label_topic_name": "Название",
"label_private": "Комментарий",
"private_editing_placeholder": "Виден только вам",
"label_muting_topic": "Без уведомлений",
"action_more": "Ещё",
"label_your_permissions": "Ваши права доступа:",
"label_permissions": "Права доступа:",
"label_you": "Вы:",
"label_default_access": "Права по умолчанию:",
"label_group_members": "Участники чата:",
"button_add_members": "Добавить",
"button_leave": "Отписаться",
"group_has_no_members": "Нет участников",
"action_leave_chat": "Уйти из чата",
"menu_item_info": "Информация",
"menu_item_clear_messages": "Удалить сообщения",
"menu_item_clear_messages_for_all": "Удалить для всех",
"menu_item_delete": "Удалить",
"menu_item_delete_for_all": "Удалить для всех",
"menu_item_mute": "Не уведомлять",
"menu_item_unmute": "Уведомлять",
"menu_item_delete_topic": "Удалить чат",
"menu_item_unblock": "Разблокировать",
"menu_item_block": "Заблокировать",
"menu_item_member_delete": "Отписать",
"title_info": "Подробности",
"new_password_placeholder": "Введите новый пароль",
"label_reset_password": "Отправить емейл для смены пароля:",
"credential_email_prompt": "Регистрационный емейл",
"button_reset": "Изменить",
"button_send_request": "Отправить",
"action_cancel": "отменить",
"upload_finishing": "завершение...",
"no_contacts": "Ничего нет :-(",
"contacts_not_found_short": "Нет контактов для запроса '{query}'",
"title_all_contacts": "Все контакты",
"error_invalid_id": "Неверный ID",
"group_user_id_prompt": "ID чата или пользователя",
"button_subscribe": "Подписаться",
"topic_name_editing_placeholder": "Название чата",
"button_create": "Создать",
"badge_you": "вы",
"badge_owner": "влад.",
"update_available": "Есть новая версия приложения. <a href=\"\">Обновить</a>.",
"user_not_found": "Не найден",
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
"reconnect_now": "Подключить сейчас.",
"save_attachment": "сохранить",
"menu_item_archive_topic": "В архив",
"archived_contacts": "Чаты в архиве ({count})",
"sidepanel_title_archive": "Архив чатов",
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
"chat_invitation_accept": "Принять",
"chat_invitation_ignore": "Игнорировать",
"chat_invitation_block": "Заблокировать",
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
"enable_peers_messaging": "Разблокировать.",
"more_online_members": "+еще {overflow}",
"label_user_contacts": "Конакты"
},
"zh-CN": {
"archived_contacts": "已归档联系人 ({count})",
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
"full_name_prompt": "全名,例如张伟",
"email_prompt": "电子邮件,例如 zhang@example.com",
"button_sign_up": "注册",
"label_your_name": "你的姓名",
"label_password": "密码",
"password_unchanged_prompt": "未改变",
"label_user_id": "地址:",
"label_default_access_mode": "蓦然访问模式:",
"label_message_sound": "消息提示音:",
"label_push_notifications": "通知提醒:",
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
"title_tag_manager": "标签(用户发现)",
"button_logout": "登出",
"requested_permissions": "已请求",
"granted_permissions": "已授予",
"menu_item_edit_permissions": "编辑权限",
"label_other_user": "其他",
"title_info": "信息",
"label_topic_name": "名称",
"label_private": "私人评论",
"private_editing_placeholder": "仅自己可见",
"label_muting_topic": "已静音:",
"action_more": "更多",
"label_your_permissions": "你的权限:",
"label_permissions": "权限:",
"label_you": "你:",
"label_default_access": "默认权限模式:",
"label_group_members": "群组成员:",
"button_add_members": "添加成员",
"button_leave": "离开",
"group_has_no_members": "无成员",
"action_leave_chat": "离开",
"login_prompt": "登录",
"password_prompt": "密码",
"stay_logged_in": "保持登录",
"forgot_password_link": "忘记密码?",
"button_sign_in": "登录",
"label_client": "客户端:",
"label_server": "服务器:",
"online_now": "在线",
"last_seen_timestamp": "最后可见",
"title_not_found": "无法找到",
"unnamed_topic": "未命名",
"messages_not_readable": "无消息访问权限",
"peers_messaging_disabled": "成员间消息已禁用。",
"enable_peers_messaging": "启用",
"tabtitle_find_user": "搜索",
"tabtitle_new_group": "新群组",
"tabtitle_group_by_id": "通过 id",
"search_for_contacts": "使用搜索寻找联系人",
"new_password_placeholder": "输入新密码",
"label_reset_password": "发送密码重置邮件:",
"credential_email_prompt": "你的注册邮箱",
"button_reset": "重置",
"button_send_request": "发送请求",
"label_server_to_use": "使用的服务器:",
"label_wire_transport": "线路传输:",
"button_update": "更新",
"sidepanel_title_login": "登录",
"sidepanel_title_register": "创建账户",
"sidepanel_title_settings": "设置",
"sidepanel_title_edit_account": "编辑账户",
"sidepanel_title_newtpk": "开始新会话",
"sidepanel_title_cred": "确认凭据",
"sidepanel_title_reset": "重置密码",
"sidepanel_title_archive": "已存档会话",
"update_available": "更新可用。<a href=\"\">重新载入</a>.",
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
"reconnect_now": "立即尝试",
"phone_dative": "电话",
"email_dative": "电子邮件",
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
"numeric_confirmation_code_prompt": "仅数字",
"button_confirm": "确认",
"save_attachment": "保存",
"invalid_content": "无效内容",
"user_not_found": "未找到",
"badge_you": "你",
"badge_owner": "所有者",
"menu_item_info": "信息",
"menu_item_clear_messages": "清空消息",
"menu_item_clear_messages_for_all": "全部清除",
"menu_item_delete": "删除",
"menu_item_delete_for_all": "全部删除",
"menu_item_mute": "静音",
"menu_item_unmute": "取消静音",
"menu_item_delete_topic": "删除",
"menu_item_unblock": "取消屏蔽",
"menu_item_block": "屏蔽",
"menu_item_member_delete": "移除",
"menu_item_archive_topic": "归档",
"action_cancel": "取消",
"upload_finishing": "正在结束...",
"no_contacts": "你尚无联系人 :-(",
"contacts_not_found_short": "无联系人匹配 '{query}'",
"title_group_members": "群组成员",
"title_all_contacts": "全部联系人",
"button_ok": "好",
"button_cancel": "取消",
"download_action": "下载",
"label_file_name": "文件名:",
"label_content_type": "内容类型:",
"label_size": "大小:",
"chat_invitation": "你受邀开始新会话。你想怎么做?",
"chat_invitation_accept": "接受",
"chat_invitation_ignore": "忽略",
"chat_invitation_block": "屏蔽",
"error_invalid_id": "无效 ID",
"group_user_id_prompt": "群组或用户 ID",
"button_subscribe": "订阅",
"topic_name_editing_placeholder": "群组自由格式名称",
"button_create": "创建",
"permission_join": "加入 ({val})",
"permission_read": "读取 ({val})",
"permission_write": "写入 ({val})",
"permission_pres": "获取通知 ({val})",
"permission_admin": "批准 ({val})",
"permission_share": "分享 ({val})",
"permission_delete": "删除 ({val})",
"permission_owner": "所有者 ({val})",
"title_permissions": "权限",
"message_sending": "正在发送...",
"message_sending_failed": "发送失败",
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "消息已禁用",
"new_message_prompt": "新消息",
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
"cannot_initiate_file_upload": "无法初始化文件上传。",
"tags_not_found": "尚未定义标签。添加一些。",
"tags_editor_no_tags": "添加一些标签",
"title_manage_tags": "管理标签",
"more_online_members": "还有{overflow}个"
}
}
-3
View File
@@ -1,3 +0,0 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.15.15-rc2";
-85
View File
@@ -1,85 +0,0 @@
/* ContactsView holds all contacts-related stuff */
import React from 'react';
import { FormattedHTMLMessage, defineMessages } from 'react-intl';
import ContactList from '../widgets/contact-list.jsx';
import { updateFavicon } from '../lib/utils.js';
const messages = defineMessages({
archived_contacts_title: {
id: "archived_contacts",
defaultMessage: "Archived contacts ({count})",
description: "Label for archived chats"
}
});
export default class ContactsView extends React.Component {
constructor(props) {
super(props);
this.handleAction = this.handleAction.bind(this);
this.state = ContactsView.getDerivedStateFromProps(props, {});
}
static getDerivedStateFromProps(nextProps, prevState) {
const contacts = [];
let unreadThreads = 0;
let archivedCount = 0;
nextProps.chatList.map((c) => {
if (c.private && c.private.arch) {
if (nextProps.archive) {
contacts.push(c);
} else {
archivedCount ++;
}
} else if (!nextProps.archive) {
contacts.push(c);
unreadThreads += c.unread > 0 ? 1 : 0;
}
});
contacts.sort(function(a, b) {
return (b.touched || 0) - (a.touched || 0);
});
if (archivedCount > 0) {
contacts.push({
action: 'archive',
title: messages.archived_contacts_title,
values: {count: archivedCount}
});
}
updateFavicon(unreadThreads);
return {
contactList: contacts,
archivedCount: archivedCount
};
}
handleAction(action_ignored) {
this.props.onShowArchive();
}
render() {
return (
<FormattedHTMLMessage id="contacts_not_found"
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
description="HTML message shown in ContactList when no contacts are found">{
(no_contacts) => <ContactList
connected={this.props.connected}
contacts={this.state.contactList}
emptyListMessage={no_contacts}
topicSelected={this.props.topicSelected}
myUserId={this.props.myUserId}
showOnline={true}
showUnread={true}
onTopicSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
onAction={this.handleAction} />
}</FormattedHTMLMessage>
);
}
};
-125
View File
@@ -1,125 +0,0 @@
// Account registration.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
import { vcard } from '../lib/utils.js';
export default class CreateAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
login: '',
password: '',
email: '',
fn: '', // full/formatted name
imageDataUrl: null,
errorCleared: 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.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(password) {
this.setState({password: password});
}
handleEmailChange(e) {
this.setState({email: e.target.value})
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleToggleSaveToken() {
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
// TODO: check email for validity
this.setState({errorCleared: false});
this.props.onCreateAccount(
this.state.login.trim(),
this.state.password.trim(),
vcard(this.state.fn, this.state.imageDataUrl),
{'meth': 'email', 'val': this.state.email});
}
render() {
let submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form className="panel-form-column" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<FormattedMessage id="login_prompt">{
(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">{
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
value={this.state.password} onFinished={this.handlePasswordChange}
required={true} />
}</FormattedMessage>
</div>
<AvatarUpload
onImageChanged={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>
<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>
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<FormattedMessage id="stay_logged_in">{
(stay_logged_in) => <label htmlFor="save-token">&nbsp;{stay_logged_in}</label>
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
description="Create account button [Sign Up]" />
</button>
</div>
</form>
);
}
};
-313
View File
@@ -1,313 +0,0 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, asEmail, asPhone, vcard } from '../lib/utils.js';
export default class EditAccountView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
const defacs = me.getDefaultAccess();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageUrl(me.public ? me.public.photo : null),
auth: defacs ? defacs.auth : null,
anon: defacs ? defacs.anon : null,
tags: me.tags(),
credentials: me.getCredentials() || [],
addCredActive: false,
addCredInvalid: false,
newCred: '',
showPermissionEditorFor: undefined,
previousOnTags: me.onTagsUpdated
};
this.tnNewTags = this.tnNewTags.bind(this);
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
this.handleStartAddCred = this.handleStartAddCred.bind(this);
this.handleCredChange = this.handleCredChange.bind(this);
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
this.handleCredEntered = this.handleCredEntered.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
}
componentDidMount() {
const me = this.props.tinode.getMeTopic();
me.onCredsUpdated = this.tnCredsUpdated;
me.onTagsUpdated = this.tnNewTags;
}
componentWillUnmount() {
const me = this.props.tinode.getMeTopic();
me.onTagsUpdated = this.state.previousOnTags;
me.onCredsUpdated = undefined;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
tnCredsUpdated(creds) {
console.log("tnCredsUpdated", creds, new Error());
this.setState({credentials: creds || []});
}
handleFullNameUpdate(fn) {
this.setState({fullName: fn});
this.props.onUpdateAccount(undefined, vcard(fn, null));
}
handlePasswordUpdate(pwd) {
this.setState({password: pwd});
this.props.onUpdateAccount(pwd);
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onUpdateAccount(undefined, vcard(null, img));
}
handleCheckboxClick(what, checked) {
if (what == 'sound') {
this.props.onToggleMessageSounds(checked);
} else if (what == 'alert') {
this.props.onTogglePushNotifications(checked);
}
}
handleStartAddCred() {
this.setState({addCredActive: true});
}
handleCredChange(e) {
this.setState({newCred: e.target.value, addCredInvalid: false});
}
handleCredKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({newCred: '', addCredActive: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleCredEntered(e);
}
}
handleCredEntered(e) {
let value = this.state.newCred.trim();
if (!value) {
this.setState({addCredActive: false, addCredInvalid: false});
return;
}
let val = asPhone(value);
let method;
if (val) {
method = 'tel';
} else {
val = asEmail(value);
if (val) {
method = 'email';
}
}
if (method) {
this.props.onCredAdd(method, val);
this.setState({addCredActive: false, newCred: ''});
} else {
this.setState({addCredInvalid: true});
}
}
handleLaunchPermissionsEditor(which) {
this.setState({
showPermissionEditorFor: which,
editedPermissions: this.state[which]
});
}
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
}
handlePermissionsChanged(perm) {
let defacs = {};
defacs[this.state.showPermissionEditorFor] = perm;
this.props.onUpdateAccount(undefined, undefined, defacs);
let newState = {showPermissionEditorFor: undefined};
newState[this.state.showPermissionEditorFor] = perm;
this.setState(newState);
}
handleTagsUpdated(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
}
render() {
/*
let tags = [];
this.state.tags.map((tag) => {
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
});
if (tags.length == 0) {
tags = <i><FormattedMessage id="tags_not_found" /></i>;
}
*/
let credentials = [];
this.state.credentials.map((cred) => {
credentials.push(<div key={cred.meth + ":" + cred.val}>{cred.meth}: <tt>{cred.val}</tt>
{!cred.done ?
<a href="javascript:;"
onClick={this.props.onCredConfirm.bind(this, cred.meth, cred.val)}>confirm</a>
: null} <a href="javascript:;" onClick={this.props.onCredDelete.bind(this, cred.meth, cred.val)}><i
className="material-icons gray">delete_outline</i></a></div>);
});
return (
<React.Fragment>{this.state.showPermissionEditorFor ?
<PermissionsEditor
mode={this.state.editedPermissions}
skip="O"
onSubmit={this.handlePermissionsChanged}
onCancel={this.handleHidePermissionsEditor} />
:
<div id="edit-account" className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<div><label className="small">
<FormattedMessage id="label_your_name" defaultMessage="Your name"
description="Label for full name editing" />
</label></div>
<div><FormattedMessage id="full_name_prompt">{
(full_name_placeholder) => <InPlaceEdit
placeholder={full_name_placeholder}
value={this.state.fullName}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage></div>
<div><label className="small">
<FormattedMessage id="label_password" defaultMessage="Password"
description="Label for password editing" />
</label></div>
<div>
<FormattedMessage id="password_unchanged_prompt" defaultMessage="Unchanged"
description="Message in editor while password is unchanged">{
(password_unchanged) => <InPlaceEdit
placeholder={password_unchanged}
type="password"
onFinished={this.handlePasswordUpdate} />
}</FormattedMessage>
</div>
</div>
<AvatarUpload
avatar={this.state.avatar}
uid={this.props.myUserId}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="hr" />
<div className="panel-form-row">
<label htmlFor="message-sound">
<FormattedMessage id="label_message_sound" defaultMessage="Message sound:"
description="Label for message sounds toggle" />
</label>
<CheckBox name="sound" id="message-sound"
checked={this.props.messageSounds}
onChange={this.handleCheckboxClick} />
</div>
<div className="panel-form-row">
<label htmlFor="desktop-alerts">
{this.props.desktopAlertsEnabled ?
<FormattedMessage id="label_push_notifications" defaultMessage="Notification alerts:"
description="Label for push notifications switch" />
:
<FormattedMessage id="label_push_notifications_disabled"
defaultMessage="Notification alerts (requires HTTPS):"
description="Label for push notifications switch" />
}
</label>
<CheckBox name="alert" id="desktop-alerts"
checked={this.props.desktopAlerts}
onChange={this.props.desktopAlertsEnabled ? this.handleCheckboxClick : null} />
</div>
<div className="hr" />
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
description="Section title for TagManager">{
(title_tag_manager) => <TagManager
title={title_tag_manager}
activated={false}
tags={this.state.tags}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
<div className="panel-form-column">
<div>
<label className="small">
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
description="Label for user contacts" />
</label>
</div>
<div className="quoted">
{credentials}
{this.state.addCredActive ?
<input type="text" value={this.state.value}
className={this.state.addCredInvalid ? "invalid" : null}
placeholder="Phone number or email" required="required" autoFocus
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
: null}
<div><a href="javascript:;" onClick={this.handleStartAddCred}>+ Add another</a></div>
</div>
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="Address:"
description="Label for user address (ID)" /></label>
<tt>{this.props.myUserId}</tt>
</div>
<div>
<label className="small">
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
description="Label for default access mode" />
</label>
</div>
<div className="quoted">
<div>Auth: <tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>{this.state.auth}</tt></div>
<div>Anon: <tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>{this.state.anon}</tt></div>
</div>
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="javascript:;" className="red flat-button" onClick={this.props.onLogout}>
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
defaultMessage="Logout" description="Button [Logout]" />
</a>
</div>
</div>
}</React.Fragment>
);
}
};
-640
View File
@@ -1,640 +0,0 @@
// InfoView: panel with topic/user info.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import MenuCancel from '../widgets/menu-cancel.jsx';
import MoreButton from '../widgets/more-button.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { NO_ACCESS_MODE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, vcard } from '../lib/utils.js';
const messages = defineMessages({
requested: {
id: 'requested_permissions',
defaultMessage: 'Requested',
description: 'Title for permissions'
},
granted: {
id: 'granted_permissions',
defaultMessage: 'Granted',
description: 'Title for permissions'
},
edit_permissions: {
id: 'menu_item_edit_permissions',
defaultMessage: 'Edit permissions',
description: 'Menu item [Edit permissions]'
},
other_user: {
id: 'label_other_user',
defaultMessage: 'Other',
description: 'Label for the other user when the user is unnamed'
}
});
class InfoView extends React.Component {
constructor(props) {
super(props);
this.state = {
topic: null,
owner: false,
admin: false,
sharer: false,
muted: false,
address: null,
groupTopic: undefined,
fullName: undefined,
avatar: null,
private: null,
selectedContact: null,
access: null,
modeGiven: null,
modeWant: null,
modeGiven2: null, // P2P topic, the other user mode given
modeWant2: null, // P2P topic, the other user mode want
auth: null,
anon: null,
contactList: [],
tags: [],
showMemberPanel: false,
showPermissionEditorFor: undefined,
moreInfoExpanded: false,
previousMetaDesc: undefined,
previousSubsUpdated: undefined,
previousTagsUpdated: undefined
};
this.resetSubs = this.resetSubs.bind(this);
this.resetDesc = this.resetDesc.bind(this);
this.onMetaDesc = this.onMetaDesc.bind(this);
this.onSubsUpdated = this.onSubsUpdated.bind(this);
this.onTagsUpdated = this.onTagsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleMuted = this.handleMuted.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleLeave = this.handleLeave.bind(this);
this.handleMemberSelected = this.handleMemberSelected.bind(this);
this.handleMoreInfo = this.handleMoreInfo.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
}
// No need to separately handle component mount.
componentDidUpdate(props) {
const topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
if (this.onMetaDesc != topic.onMetaDesc) {
this.previousMetaDesc = topic.onMetaDesc;
topic.onMetaDesc = this.onMetaDesc;
this.previousSubsUpdated = topic.onSubsUpdated;
topic.onSubsUpdated = this.onSubsUpdated;
if (topic.getType() == 'grp') {
this.previousTagsUpdated = topic.onTagsUpdated;
topic.onTagsUpdated = this.onTagsUpdated;
} else {
this.previousTagsUpdated = undefined;
}
}
if (this.state.topic != props.topic) {
this.setState({topic: props.topic});
this.resetDesc(topic, props);
this.resetSubs(topic, props);
}
}
componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.setState({topic: null});
topic.onMetaDesc = this.previousMetaDesc;
topic.onSubsUpdated = this.previousSubsUpdated;
topic.onTagsUpdated = this.previousTagsUpdated;
}
resetSubs(topic, props) {
const newState = {contactList: []};
if (topic.getType() == 'p2p') {
// Fetch the other party in the p2p conversation.
// Topic may not be ready yet, so check if user is found.
const user2 = topic.subscriber(props.topic);
if (user2) {
newState.modeGiven2 = user2.acs.getGiven();
newState.modeWant2 = user2.acs.getWant();
} else {
newState.modeGiven2 = NO_ACCESS_MODE;
newState.modeWant2 = NO_ACCESS_MODE;
}
} else {
topic.subscribers((sub) => {
newState.contactList.push(sub);
}, this);
}
this.setState(newState);
}
resetDesc(topic, props) {
const defacs = topic.getDefaultAccess() || {};
const acs = topic.getAccessMode();
this.setState({
owner: acs && acs.isOwner(),
admin: acs && acs.isAdmin(),
sharer: acs && acs.isSharer(),
muted: acs && acs.isMuted(),
fullName: topic.public ? topic.public.fn : undefined,
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
private: topic.private ? topic.private.comment : null,
address: topic.name,
groupTopic: (topic.getType() == 'grp'),
showMemberPanel: false,
access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined,
modeWant: acs ? acs.getWant() : undefined,
auth: defacs.auth,
anon: defacs.anon
});
if (topic.getType() == 'grp' && acs && acs.isOwner()) {
// Requesting tags: owner is editing the topic.
topic.getMeta(topic.startMetaQuery().withTags().build());
}
}
onMetaDesc(desc) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic, this.props);
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
this.previousMetaDesc(desc);
}
}
onSubsUpdated(subs) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetSubs(topic, this.props);
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
this.previousSubsUpdated(subs);
}
}
onTagsUpdated(tags) {
this.setState({tags: tags});
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
this.previousTagsUpdated();
}
}
handleFullNameUpdate(fn) {
fn = fn.trim();
if (this.state.fullName !== fn) {
this.setState({fullName: fn});
this.props.onTopicDescUpdate(this.props.topic, vcard(fn, null), null);
}
}
handlePrivateUpdate(priv) {
if (this.state.priv !== priv) {
this.setState({private: priv});
this.props.onTopicDescUpdate(this.props.topic, null, priv || Tinode.DEL_CHAR);
}
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onTopicDescUpdate(this.props.topic, vcard(null, img), null);
}
handleMuted(ignored, checked) {
this.setState({muted: checked});
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
}
handlePermissionsChanged(perm) {
switch (this.state.showPermissionEditorFor) {
case 'auth':
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
break;
case 'anon':
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
break;
case 'mode':
case 'want':
this.props.onChangePermissions(this.props.topic, perm);
break;
case 'given':
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
break;
case 'user':
this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
break;
}
this.setState({showPermissionEditorFor: undefined});
}
handleLaunchPermissionsEditor(which, uid) {
const {formatMessage} = this.props.intl;
let toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar;
switch (which) {
case 'mode':
toEdit = this.state.access;
break;
case 'want':
toEdit = this.state.modeWant;
toCompare = this.state.modeGiven;
toSkip = this.state.groupTopic ? 'O' : 'ASDO';
titleEdit = formatMessage(messages.requested);
titleCompare = formatMessage(messages.granted);
break;
case 'given':
toEdit = this.state.modeGiven2;
toCompare = this.state.modeWant2;
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
titleEdit = formatMessage(messages.granted);
titleCompare = formatMessage(messages.requested);
break;
case 'auth':
toEdit = this.state.auth;
toSkip = 'O';
break;
case 'anon':
toEdit = this.state.anon;
toSkip = 'O';
break;
case 'user': {
let topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
var user = topic.subscriber(uid);
if (!user || !user.acs) {
return;
}
toEdit = user.acs.getGiven();
toCompare = user.acs.getWant();
toSkip = this.state.owner ? '' : 'O';
titleEdit = formatMessage(messages.granted);
titleCompare = formatMessage(messages.requested);
if (user.public) {
userTitle = user.public.fn;
userAvatar = user.public.photo;
}
break;
}
default:
console.log("Unknown permission editing mode '" + which + "'");
break;
}
this.setState({
showPermissionEditorFor: which,
userPermissionsEdited: uid,
userPermissionsTitle: userTitle,
userPermissionsAvatar: userAvatar,
editedPermissions: toEdit,
immutablePermissions: toCompare,
editedPermissionsTitle: titleEdit,
immutablePermissionsTitle: titleCompare,
editedPermissionsSkipped: toSkip,
});
}
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
}
handleShowAddMembers() {
this.props.onInitFind();
this.setState({showMemberPanel: true});
}
handleHideAddMembers() {
this.setState({showMemberPanel: false});
}
handleMemberUpdateRequest(members, added, removed) {
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
this.setState({showMemberPanel: false});
}
handleLeave() {
this.props.onLeaveTopic(this.props.topic);
}
handleMemberSelected(uid) {
this.setState({selectedContact: uid});
}
handleMoreInfo(open) {
this.setState({moreInfoExpanded: open});
}
handleTagsUpdated(tags) {
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
this.props.onTopicTagsUpdate(this.props.topic, tags);
}
}
handleContextMenu(params) {
const {formatMessage} = this.props.intl;
const instance = this;
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
const user = topic.subscriber(params.topicName);
if (!user || !user.acs) {
return;
}
const menuItems = [
{title: formatMessage(messages.edit_permissions), handler: function() {
instance.handleLaunchPermissionsEditor('user', params.topicName);
}},
'member_delete',
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
];
this.props.showContextMenu({
topicName: this.props.topic,
x: params.x,
y: params.y,
user: params.topicName}, menuItems);
}
render() {
const {formatMessage} = this.props.intl;
return (
<div id="info-view">
<div className="caption-panel" id="info-caption-panel">
<div className="panel-title" id="info-title">
<FormattedMessage id="title_info" defaultMessage="Info" description="Title for InfoView" />
</div>
<div>
<MenuCancel onCancel={this.props.onCancel} />
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} /> : null}
{this.state.showMemberPanel ?
<GroupManager
members={this.state.contactList}
requiredMember={this.props.myUserId}
myUserId={this.props.myUserId}
contacts={this.props.searchableContacts}
onCancel={this.handleHideAddMembers}
onSubmit={this.handleMemberUpdateRequest} />
:
this.state.showPermissionEditorFor ?
<PermissionsEditor
mode={this.state.editedPermissions}
compare={this.state.immutablePermissions}
skip={this.state.editedPermissionsSkipped}
modeTitle={this.state.editedPermissionsTitle}
compareTitle={this.state.immutablePermissionsTitle}
userTitle={this.state.userPermissionsTitle}
item={this.state.userPermissionsEdited}
userAvatar={this.state.userPermissionsAvatar}
onSubmit={this.handlePermissionsChanged}
onCancel={this.handleHidePermissionsEditor}
/>
:
<div id="info-view-content" className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<div><label className="small">
<FormattedMessage id="label_topic_name" defaultMessage="Name"
description="Label for editing topic name" />
</label></div>
<div><InPlaceEdit
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
readOnly={!this.state.owner}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} /></div>
<div>
<label className="small">
<FormattedMessage id="label_private" defaultMessage="Private comment"
description="Label for editing 'private'" />
</label>
</div>
<div>
<FormattedMessage id="private_editing_placeholder"
defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{
(private_placeholder) => <InPlaceEdit
placeholder={private_placeholder}
value={this.state.private}
onFinished={this.handlePrivateUpdate} />
}</FormattedMessage>
</div>
</div>
<AvatarUpload
avatar={this.state.avatar}
readOnly={!this.state.owner}
uid={this.props.topic}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label>
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
description="Label for Muting/unmuting the topic" />
</label>
<CheckBox name="P" checked={this.state.muted}
onChange={this.handleMuted} />
</div>
<FormattedMessage id="action_more" defaultMessage="More"
description="Action for showing more content">{
(more) => <MoreButton
title={more}
open={this.state.moreInfoExpanded}
onToggle={this.handleMoreInfo} />
}</FormattedMessage>
{this.state.moreInfoExpanded ?
<div className="panel-form-column">
<div className="panel-form-row">
<label><FormattedMessage id="label_user_id" /></label>
<tt>{this.state.address}</tt>
</div>
{this.state.groupTopic ?
<div className="panel-form-row">
<label>
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:"
description="Label for current user permissions" />
</label>
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt>
</div>
:
<div>
<div>
<label className="small">
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>
<FormattedMessage id="label_you" defaultMessage="You:"
description="Label for the current user" /> &nbsp;<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt></div>
<div>{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}:
&nbsp;<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
{this.state.modeGiven2}
</tt>
</div>
</div>
</div>
}
{this.state.sharer && (this.state.auth || this.state.anon) ?
<div>
<div>
<label className="small">
<FormattedMessage id="label_default_access" defaultMessage="Default access mode:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>Auth: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
{this.state.auth}
</tt>
:
<tt>{this.state.auth}</tt>
}
</div>
<div>Anon: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
{this.state.anon}
</tt>
:
<tt>{this.state.anon}</tt>
}
</div>
</div>
</div>
:
null
}
</div>
:
null
}
</div>
<div className="hr" />
{this.state.owner ?
<FormattedMessage id="title_tag_manager">{
(tags) => <TagManager
title={tags}
tags={this.state.tags}
activated={false}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
:
null
}
{this.state.owner ? <div className="hr" /> : null }
{this.state.groupTopic ?
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
description="Section title or label" />
</label>
</div>
<div className="panel-form-row">
{this.state.sharer ?
<a href="javascript:;" className="flat-button" onClick={this.handleShowAddMembers}>
<i className="material-icons">person_add</i> <FormattedMessage id="button_add_members"
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
</a>
: null}
{!this.state.owner ?
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_leave"
defaultMessage="Leave" description="Flat button [Leave] (topic)" />
</a>
: null}
</div>
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
description="Shown in place of group members">{
(no_members) => <ContactList
contacts={this.state.contactList}
myUserId={this.props.myUserId}
emptyListMessage={no_members}
topicSelected={this.state.selectedContact}
showOnline={false}
showUnread={false}
showMode={true}
noScroll={true}
onTopicSelected={this.handleMemberSelected}
showContextMenu={this.state.admin ? this.handleContextMenu : false}
/>
}</FormattedMessage>
</div>
:
<div className="panel-form-row">
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> <FormattedMessage id="action_leave_chat"
defaultMessage="Leave" description="Action [Leave] chat" />
</a>
</div>
}
</div>
}
</div>
);
}
};
export default injectIntl(InfoView);
-96
View File
@@ -1,96 +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';
import LocalStorageUtil from '../lib/local-storage.js';
export default class LoginView extends React.Component {
constructor(props) {
super(props);
this.state = {
login: props.login,
password: '',
hostName: props.serverAddress,
saveToken: LocalStorageUtil.getObject('keep-logged-in')
};
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() {
LocalStorageUtil.setObject('keep-logged-in', !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() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form id="login-form" onSubmit={this.handleSubmit}>
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Login placeholder in LoginView">
{
(login_prompt) => <input type="text" id="inputLogin"
placeholder={login_prompt}
autoComplete="username"
value={this.state.login}
onChange={this.handleLoginChange}
required autoFocus />
}
</FormattedMessage>
<FormattedMessage id="password_prompt" defaultMessage="Password"
description="Password placeholder in LoginView">
{
(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>
);
}
};
/* END Login */
-25
View File
@@ -1,25 +0,0 @@
/* This is just a static page to display when no conversation is selected. */
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { APP_NAME } from '../config.js';
export default class LogoView extends React.PureComponent {
render() {
var version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
return (
<div id="dummy-view" className={this.props.hideSelf ? 'nodisplay' : null}>
<div>
<a href="https://github.com/tinode/chat/">
<img id="logo" alt="logo" src="img/logo.svg" />
<h2>Tinode Web</h2>
</a>
<p><FormattedMessage id="label_client" defaultMessage="Client:" /> {version}</p>
<p><FormattedMessage id="label_server" defaultMessage="Server:" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
</div>
</div>
);
}
};
-670
View File
@@ -1,670 +0,0 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
const Drafty = Tinode.Drafty;
import ChatMessage from '../widgets/chat-message.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupSubs from '../widgets/group-subs.jsx';
import ImagePreview from '../widgets/image-preview.jsx';
import Invitation from '../widgets/Invitation.jsx';
import LetterTile from '../widgets/letter-tile.jsx';
import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { shortDateFormat } from '../lib/strformat.js';
const messages = defineMessages({
online_now: {
id: 'online_now',
defaultMessage: 'online now',
description: 'Indicator that the user or topic is currently online',
},
last_seen: {
id: 'last_seen_timestamp',
defaultMessage: 'Last seen',
description: 'Label for the timestamp of when the user or topic was last online'
},
'not_found': {
id: 'title_not_found',
defaultMessage: 'Not found',
description: 'Title shown when topic is not found'
}
});
// Checks if the access permissions are granted but not yet accepted.
function isUnconfirmed(acs) {
if (acs) {
const ex = acs.getExcessive() || '';
return acs.isJoiner('given') && (ex.includes('R') || ex.includes('W'));
}
return false;
}
function isPeerRestricted(acs) {
if (acs) {
const ms = acs.getMissing() || '';
return acs.isJoiner('want') && (ms.includes('R') || ms.includes('W'));
}
return false;
}
class MessagesView extends React.Component {
constructor(props) {
super(props);
this.state = MessagesView.getDerivedStateFromProps(props, {});
// this.propsChange = this.propsChange.bind(this);
this.leave = this.leave.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this);
this.handleScrollEvent = this.handleScrollEvent.bind(this);
this.handleDescChange = this.handleDescChange.bind(this);
this.handleSubsUpdated = this.handleSubsUpdated.bind(this);
this.handleNewMessage = this.handleNewMessage.bind(this);
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
this.handleImagePreview = this.handleImagePreview.bind(this);
this.handleCloseImagePreview = this.handleCloseImagePreview.bind(this);
this.handleFormResponse = this.handleFormResponse.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
this.handleBackNavigation = this.handleBackNavigation.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this);
}
componentDidMount() {
// this.propsChange(this.props, this.state);
if (this.messagesScroller) {
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
}
}
componentWillUnmount() {
this.leave(this.state.topic);
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
}
// Scroll last message into view on component update e.g. on message received
// or vertical shrinking.
componentDidUpdate(prevProps, prevState) {
if (this.messagesScroller) {
if (prevState.topic != this.state.topic || prevState.messages.length != this.state.messages.length) {
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
}
}
const topic = this.props.tinode.getTopic(this.state.topic);
if (this.state.topic != prevState.topic) {
if (prevState.topic) {
this.leave(prevState.topic);
this.props.readTimerHandler(null);
}
if (topic) {
topic.onData = this.handleNewMessage;
topic.onAllMessagesReceived = this.handleAllMessagesReceived;
topic.onInfo = this.handleInfoReceipt;
topic.onMetaDesc = this.handleDescChange;
topic.onSubsUpdated = this.handleSubsUpdated;
topic.onPres = this.handleSubsUpdated;
}
}
if (topic && !topic.isSubscribed() && this.props.ready &&
((this.state.topic != prevState.topic) || !prevProps.ready)) {
// Is this a new topic?
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
if (this.state.isReader || newTopic) {
// If reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
// And show "loading" spinner.
this.setState({ fetchingMessages: true });
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic});
}
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
})
.catch((err) => {
console.log("Failed subscription to", this.state.topic);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
}
}
static getDerivedStateFromProps(nextProps, prevState) {
let nextState = {};
if (!nextProps.topic) {
// Default state: no topic.
nextState = {
messages: [],
onlineSubs: [],
topic: null,
title: '',
avatar: null,
imagePreview: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false,
peerMessagingDisabled: false
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
nextState = {
topic: nextProps.topic,
imagePreview: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false
};
if (topic) {
// Topic exists.
const msgs = [];
const subs = [];
if (nextProps.connected) {
topic.subscribers((sub) => {
if (sub.online && sub.user != nextProps.myUserId) {
subs.push(sub);
}
});
}
topic.messages(function(msg) {
if (!msg.deleted) {
msgs.push(msg);
}
});
Object.assign(nextState, {
messages: msgs,
onlineSubs: subs
});
if (topic.public) {
Object.assign(nextState, {
title: topic.public.fn,
avatar: makeImageUrl(topic.public.photo)
});
} else {
Object.assign(nextState, {
title: '',
avatar: null
});
}
const peer = topic.p2pPeerDesc();
if (peer) {
Object.assign(nextState, {
peerMessagingDisabled: isPeerRestricted(peer.acs)
});
} else if (prevState.peerMessagingDisabled) {
Object.assign(nextState, {
peerMessagingDisabled: false
});
}
} else {
// Invalid topic.
Object.assign(nextState, {
messages: [],
onlineSubs: [],
title: '',
avatar: null,
peerMessagingDisabled: false
});
}
}
if (nextProps.acs) {
if (nextProps.acs.isWriter() != prevState.isWriter) {
nextState.isWriter = !prevState.isWriter;
}
if (nextProps.acs.isReader() != prevState.isReader) {
nextState.isReader = !prevState.isReader;
}
if (!nextProps.acs.isReader('given') != prevState.readingBlocked) {
nextState.readingBlocked = !prevState.readingBlocked;
}
} else {
if (prevState.isWriter) {
nextState.isWriter = false;
}
if (prevState.isReader) {
nextState.isReader = false;
}
if (!prevState.readingBlocked) {
prevState.readingBlocked = true;
}
}
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
nextState.unconfirmed = !prevState.unconformed;
}
// Clear subscribers online when there is no connection.
if (!nextProps.connected && prevState.onlineSubs && prevState.onlineSubs.length > 0) {
nextState.onlineSubs = [];
}
return nextState;
}
leave(oldTopicName) {
if (!oldTopicName) {
return;
}
let oldTopic = this.props.tinode.getTopic(oldTopicName);
if (oldTopic && oldTopic.isSubscribed()) {
oldTopic.leave(false)
.catch(() => { /* do nothing here */ })
.finally(() => {
// We don't care if the request succeeded or failed.
// The topic is dead regardless.
this.setState({fetchingMessages: false});
oldTopic.onData = undefined;
oldTopic.onAllMessagesReceived = undefined;
oldTopic.onInfo = undefined;
oldTopic.onMetaDesc = undefined;
oldTopic.onSubsUpdated = undefined;
oldTopic.onPres = undefined;
});
}
}
handleScrollReference(node) {
if (node) {
node.addEventListener('scroll', this.handleScrollEvent);
this.messagesScroller = node;
}
}
// Get older messages
handleScrollEvent(event) {
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
if (event.target.scrollTop <= 0) {
this.setState((prevState, props) => {
const newState = {};
if (!prevState.fetchingMessages) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
newState.fetchingMessages = true;
topic.getMessagesPage(MESSAGES_PAGE).catch((err) => {
this.setState({fetchingMessages: false});
this.props.onError(err.message, 'err');
});
}
}
return newState;
});
}
}
handleDescChange(desc) {
if (desc.public) {
this.setState({
title: desc.public.fn,
avatar: makeImageUrl(desc.public.photo)
});
} else {
this.setState({
title: '',
avatar: null
});
}
if (desc.acs) {
this.setState({
isWriter: desc.acs.isWriter(),
isReader: desc.acs.isReader(),
readingBlocked: !desc.acs.isReader('given'),
unconfirmed: isUnconfirmed(desc.acs),
});
}
}
handleSubsUpdated() {
if (this.state.topic) {
const subs = [];
const topic = this.props.tinode.getTopic(this.state.topic);
topic.subscribers((sub) => {
if (sub.online && sub.user != this.props.myUserId) {
subs.push(sub);
}
});
const newState = {onlineSubs: subs};
const peer = topic.p2pPeerDesc();
if (peer) {
Object.assign(newState, {
peerMessagingDisabled: isPeerRestricted(peer.acs)
});
} else if (this.state.peerMessagingDisabled) {
Object.assign(newState, {
peerMessagingDisabled: false
});
}
this.setState(newState);
}
}
handleNewMessage(msg) {
// Regenerate messages list
const topic = this.props.tinode.getTopic(this.state.topic);
const newState = {messages: []};
topic.messages(function(m) {
if (!m.deleted) {
newState.messages = newState.messages.concat(m);
}
});
// msg could be null if one or more messages were deleted.
if (msg && !msg.deleted) {
// If the message is added to the end of the message list,
// scroll to the bottom.
if (topic.isNewMessage(msg.seq)) {
newState.scrollPosition = 0;
}
// Aknowledge messages except own messages. They are
// automatically assumed to be read and recived.
const status = topic.msgStatus(msg);
if (status >= Tinode.MESSAGE_STATUS_SENT && msg.from != this.props.myUserId) {
this.props.readTimerHandler(() => {
topic.noteRead(msg.seq);
});
}
this.props.onData(msg);
}
this.setState(newState);
}
handleAllMessagesReceived(count) {
this.setState({fetchingMessages: false});
}
handleInfoReceipt(info) {
switch (info.what) {
case 'kp': {
clearTimeout(this.keyPressTimer);
var instance = this;
this.keyPressTimer = setTimeout(function() {
instance.setState({typingIndicator: false});
}, KEYPRESS_DELAY + 1000);
if (!this.state.typingIndicator) {
this.setState({typingIndicator: true});
}
break;
}
case 'read':
case 'recv':
// Redraw due to changed recv/read status.
this.forceUpdate();
break;
default:
console.log("Other change in topic: ", info.what);
}
}
handleImagePreview(content) {
this.setState({ imagePreview: content });
}
handleCloseImagePreview() {
this.setState({ imagePreview: null });
}
handleFormResponse(action, text, data) {
if (action == 'pub') {
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
} else if (action == 'url') {
const url = new URL(data.ref);
const params = url.searchParams;
for (let key in data.resp) {
if (data.resp.hasOwnProperty(key)) {
params.set(key, data.resp[key]);
}
}
['name', 'seq'].map(function(key) {
if (data[key]) {
params.set(key, data[key]);
}
});
params.set('uid', this.props.myUserId);
url.search = params;
window.open(url, '_blank');
} else {
console.log("Unknown action in form", action);
}
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
}
handleShowContextMenuMessage(params) {
params.topicName = this.state.topic;
const menuItems = ['message_delete'];
const topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
const acs = topic.getAccessMode();
if (acs && acs.isDeleter()) {
menuItems.push('message_delete_hard');
}
}
this.props.showContextMenu(params, menuItems);
}
handleBackNavigation() {
this.props.onHideMessagesView();
}
handleNewChatAcceptance(action) {
this.props.onNewChat(this.state.topic, action);
}
handleEnablePeer() {
this.props.onChangePermissions(this.state.topic, DEFAULT_ACCESS_MODE, this.state.topic);
}
render() {
const {formatMessage} = this.props.intl;
let component;
if (this.state.topic) {
const topic = this.props.tinode.getTopic(this.state.topic);
const groupTopic = topic.getType() == 'grp';
let messageNodes = [];
let previousFrom = null;
let chatBoxClass = null;
for (let i=0; i<this.state.messages.length; i++) {
let msg = this.state.messages[i];
let nextFrom = null;
if (i + 1 < this.state.messages.length) {
nextFrom = this.state.messages[i+1].from
}
let sequence = 'single';
if (msg.from == previousFrom) {
if (msg.from == nextFrom) {
sequence = 'middle';
} else {
sequence = 'last';
}
} else if (msg.from == nextFrom) {
sequence = 'first';
}
previousFrom = msg.from;
const isReply = !(msg.from == this.props.myUserId);
const deliveryStatus = topic.msgStatus(msg);
let userName, userAvatar, userFrom;
if (groupTopic) {
const user = topic.userDesc(msg.from);
if (user && user.public) {
userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo);
}
userFrom = msg.from;
chatBoxClass='chat-box group';
} else {
chatBoxClass='chat-box';
}
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
content={msg.content}
mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply} seq={msg.seq}
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
viewportWidth={this.props.viewportWidth}
showContextMenu={this.handleShowContextMenuMessage}
onImagePreview={this.handleImagePreview}
onFormResponse={this.handleFormResponse}
onError={this.props.onError}
key={msg.seq} />
);
}
let lastSeen = null;
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
if (cont.online) {
lastSeen = formatMessage(messages.online_now);
} else if (cont.seen) {
lastSeen = formatMessage(messages.last_seen) + ": " +
shortDateFormat(cont.seen.when, this.props.intl.locale);
// TODO: also handle user agent in c.seen.ua
}
}
const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
component = (
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
<div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ?
<a href="javascript:;" id="hide-message-view" onClick={this.handleBackNavigation}>
<i className="material-icons">arrow_back</i>
</a>
:
null}
<div className="avatar-box">
<LetterTile
avatar={avatar}
topic={this.state.topic}
title={this.state.title} />
<span className={online} />
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{
this.state.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>
}</div>
<div id="topic-last-seen">{lastSeen}</div>
</div>
{groupTopic ?
<GroupSubs
subscribers={this.state.onlineSubs} /> :
<div id="topic-users" />
}
<div>
<a href="javascript:;" onClick={this.handleContextClick}>
<i className="material-icons">more_vert</i>
</a>
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} />
: null}
<LoadSpinner show={this.state.fetchingMessages} />
<div id="messages-container">
<div id="messages-panel" ref={this.handleScrollReference}>
<ul id="scroller" className={chatBoxClass}>
{messageNodes}
</ul>
</div>
{!this.state.isReader ?
<div id="write-only-background">
{this.state.readingBlocked ?
<div id="write-only-note">
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
description="Message shown in topic without the read access" />
</div>
: null }
</div>
: null }
</div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note">
<i className="material-icons secondary">block</i> <FormattedMessage
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
description="Shown when the p2p peer's messaging is disabled" /> <a href="javascript:;"
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
</div> : null}
{this.state.unconfirmed ?
<Invitation onAction={this.handleNewChatAcceptance} />
:
<SendMessage
tinode={this.props.tinode}
topic={this.props.topic}
disabled={!this.state.isWriter}
sendMessage={this.props.sendMessage}
onError={this.props.onError} />}
{this.state.imagePreview ?
<ImagePreview content={this.state.imagePreview}
onClose={this.handleCloseImagePreview} /> : null}
</div>
);
} else {
component = (
<LogoView hideSelf={this.props.hideSelf}
serverVersion={this.props.serverVersion}
serverAddress={this.props.serverAddress} />
);
}
return component;
}
};
export default injectIntl(MessagesView);
-101
View File
@@ -1,101 +0,0 @@
// Create new topic and invite users or send an invite.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ContactList from '../widgets/contact-list.jsx';
import NewTopicById from '../widgets/new-topic-by-id.jsx';
import NewTopicGroup from '../widgets/new-topic-group.jsx';
import SearchContacts from '../widgets/search-contacts.jsx';
import HashNavigation from '../lib/navigation.js';
import { vcard } from '../lib/utils.js';
export default class NewTopicView extends React.Component {
constructor(props) {
super(props);
this.state = {
tabSelected: 'p2p'
};
this.handleTabClick = this.handleTabClick.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
this.handleGroupByID = this.handleGroupByID.bind(this);
}
componentDidMount() {
this.props.onInitFind();
}
handleTabClick(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
handleContactSelected(sel) {
if (this.state.tabSelected === 'p2p') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(sel, undefined);
}
}
handleNewGroupSubmit(name, dataUrl, priv, tags) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
}
handleGroupByID(topicName) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(topicName);
}
render() {
return (
<div className="flex-column">
<ul className="tabbar">
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
<a href="javascript:;" data-id="p2p" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_find_user" defaultMessage="find"
description="Tab title Find" />
</a>
</li>
<li className={this.state.tabSelected === "grp" ? "active" : null}>
<a href="javascript:;" data-id="grp" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_new_group" defaultMessage="new group"
description="Tab title New Group" />
</a>
</li>
<li className={this.state.tabSelected === "byid" ? "active" : null}>
<a href="javascript:;" data-id="byid" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_group_by_id" defaultMessage="by id"
description="Tab title Find topic by ID" />
</a>
</li>
</ul>
{this.state.tabSelected === 'grp' ?
<NewTopicGroup onSubmit={this.handleNewGroupSubmit} /> :
this.state.tabSelected === 'byid' ?
<NewTopicById
onSubmit={this.handleGroupByID}
onError={this.props.onError} /> :
<div className="flex-column">
<SearchContacts type="p2p"
onSearchContacts={this.props.onSearchContacts} />
<FormattedMessage id="search_for_contacts" defaultMessage="Use search to find contacts"
description="Prompt in search field.">{
(search_for_contacts) => <ContactList
contacts={this.props.searchResults}
myUserId={this.props.myUserId}
emptyListMessage={search_for_contacts}
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
}</FormattedMessage>
</div>}
</div>
);
}
};
-87
View File
@@ -1,87 +0,0 @@
// A password reset form.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import VisiblePassword from '../widgets/visible-password.jsx';
import HashNavigation from '../lib/navigation.js';
export default class PasswordResetView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
email: '',
password: ''
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
}
componentDidMount() {
let parsed = HashNavigation.parseUrlHash(window.location.hash);
this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
}
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
} else {
this.props.onRequest('email', this.state.email.trim());
}
}
handleEmailChange(e) {
this.setState({email: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
render() {
let reset = (this.state.token && this.state.scheme);
return (
<form id="password-reset-form" onSubmit={this.handleSubmit}>
{reset ?
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
description="Placeholder for entering new password">{
(placeholder) => <VisiblePassword
placeholder={placeholder}
autoComplete="new-password"
value={this.state.password}
required={true} autoFocus={true}
onChange={this.handlePasswordChange} />
}</FormattedMessage>
:
<React.Fragment>
<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>
</React.Fragment>
}
<div className="dialog-buttons">
<button className="blue" type="submit">{reset ?
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" /> :
<FormattedMessage id="button_send_request" defaultMessage="Send request"
description="Button [Send request]" />
}</button>
</div>
</form>
);
}
}
-84
View File
@@ -1,84 +0,0 @@
// Tinode config panel.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import HostSelector from '../widgets/host-selector.jsx';
export default class SettingsView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
transport: props.transport || 'def',
serverAddress: props.serverAddress,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleTransportSelected = this.handleTransportSelected.bind(this);
this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
}
handleSubmit(e) {
e.preventDefault();
this.props.onUpdate({
transport: this.state.transport,
serverAddress: this.state.serverAddress
});
}
handleTransportSelected(e) {
this.setState({transport: e.currentTarget.value});
}
handleServerAddressChange(name) {
this.setState({serverAddress: name});
}
render() {
const names = {def: "default", ws: "websocket", lp: "long polling"};
var transportOptions = [];
var instance = this;
['def', 'ws', 'lp'].map(function(item) {
var id = 'transport-' + item;
var name = names[item];
transportOptions.push(
<li key={item}>
<input type="radio" id={id} name="transport-select" value={item}
checked={instance.state.transport === item}
onChange={instance.handleTransportSelected} />
<label htmlFor={id}>{name}</label>
</li>
);
});
return (
<form id="settings-form" className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_server_to_use" defaultMessage="Server to use:"
description="Label for server selector in SettingsView" />
</label>
</div>
<HostSelector serverAddress={this.state.serverAddress}
onServerAddressChange={this.handleServerAddressChange} />
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_wire_transport" defaultMessage="Wire transport:"
description="Label for wire transport selection in SettingsView" />
</label>
</div>
<div className="panel-form-row">
<ul className="quoted">
{transportOptions}
</ul>
</div>
<div className="dialog-buttons">
<button type="submit" className="blue">
<FormattedMessage id="button_update" defaultMessage="Update"
description="Button [Update]" />
</button>
</div>
</form>
);
}
};
-183
View File
@@ -1,183 +0,0 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import SideNavbar from '../widgets/side-navbar.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import ContactsView from './contacts-view.jsx';
import CreateAccountView from './create-account-view.jsx';
import EditAccountView from './edit-account-view.jsx';
import LoginView from './login-view.jsx';
import NewTopicView from './new-topic-view.jsx';
import PasswordResetView from './password-reset-view.jsx';
import SettingsView from './settings-view.jsx';
import ValidationView from './validation-view.jsx';
// Panel titles for translation.
const messages = defineMessages({
'login': {
id: 'sidepanel_title_login',
description: 'Sidepanel title for LoginView.',
defaultMessage: 'Sign In'
},
'register': {
id: 'sidepanel_title_register',
description: 'Sidepanel title for CreateAccountView.',
defaultMessage: 'Create Account'
},
'settings': {
id: 'sidepanel_title_settings',
description: 'Sidepanel title for SettingsView.',
defaultMessage: 'Settings'
},
'edit': {
id: 'sidepanel_title_edit_account',
description: 'Sidepanel title for EditAccountView.',
defaultMessage: 'Edit Account'
},
'newtpk': {
id: 'sidepanel_title_newtpk',
description: 'Sidepanel title for NewTopicView.',
defaultMessage: 'Start New Chat'
},
'cred': {
id: 'sidepanel_title_cred',
description: 'Sidepanel title for ValidationView.',
defaultMessage: 'Confirm Credentials'
},
'reset': {
id: 'sidepanel_title_reset',
description: 'Sidepanel title for PasswordResetView.',
defaultMessage: 'Reset Password'
},
'archive': {
id: 'sidepanel_title_archive',
description: 'Sidepanel title for ContactsView-Archive.',
defaultMessage: 'Archived Chats'
}
});
class SidepanelView extends React.Component {
constructor(props) {
super(props);
this.handleLoginRequested = this.handleLoginRequested.bind(this);
}
handleLoginRequested(login, password) {
this.props.onLoginRequest(login, password);
}
render() {
const {formatMessage} = this.props.intl;
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
let title, avatar;
if (view == 'contacts') {
title = this.props.title;
avatar = this.props.avatar ? this.props.avatar : true;
} else {
title = formatMessage(messages[view]);
avatar = false;
}
let onCancel;
if (['login', 'contacts'].indexOf(view) == -1) {
onCancel = this.props.onCancel;
}
return (
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
<SideNavbar
state={view}
title={title}
avatar={avatar}
myUserId={this.props.myUserId}
onSignUp={this.props.onSignUp}
onSettings={this.props.onSettings}
onNewTopic={this.props.onNewTopic}
onCancel={onCancel} />
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
action={this.props.errorAction}
actionText={this.props.errorActionText}
onClearError={this.props.onError} />
{view === 'login' ?
<LoginView
login={this.props.login}
disabled={this.props.loginDisabled}
onLogin={this.handleLoginRequested} /> :
view === 'register' ?
<CreateAccountView
onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
view === 'settings' ?
<SettingsView
transport={this.props.transport}
serverAddress={this.props.serverAddress}
onCancel={this.props.onCancel}
onUpdate={this.props.onGlobalSettings} /> :
view === 'edit' ?
<EditAccountView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
messageSounds={this.props.messageSounds}
desktopAlerts={this.props.desktopAlerts}
desktopAlertsEnabled={this.props.desktopAlertsEnabled}
onUpdateAccount={this.props.onUpdateAccount}
onUpdateTags={this.props.onUpdateAccountTags}
onTogglePushNotifications={this.props.onTogglePushNotifications}
onToggleMessageSounds={this.props.onToggleMessageSounds}
onCredAdd={this.props.onCredAdd}
onCredDelete={this.props.onCredDelete}
onCredConfirm={this.props.onCredConfirm}
onLogout={this.props.onLogout}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
(view === 'contacts' || view == 'archive') ?
<ContactsView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
connected={this.props.connected}
topicSelected={this.props.topicSelected}
archive={view == 'archive'}
chatList={this.props.chatList}
showContextMenu={this.props.showContextMenu}
onTopicSelected={this.props.onTopicSelected}
onShowArchive={this.props.onShowArchive} /> :
view === 'newtpk' ?
<NewTopicView
searchResults={this.props.searchResults}
onInitFind={this.props.onInitFind}
onSearchContacts={this.props.onSearchContacts}
onCreateTopic={this.props.onCreateTopic}
onError={this.props.onError} /> :
view === 'cred' ?
<ValidationView
credCode={this.props.credCode}
credMethod={this.props.credMethod}
onSubmit={this.props.onValidateCredentials}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
view === 'reset' ?
<PasswordResetView
onRequest={this.props.onPasswordResetRequest}
onReset={this.props.onResetPassword} /> :
null}
</div>
);
}
};
export default injectIntl(SidepanelView);
File diff suppressed because it is too large Load Diff
-81
View File
@@ -1,81 +0,0 @@
// ValidationView: panel for confirming credentials, like email or phone.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
phone: {
id: 'phone_dative',
defaultMessage: 'phone',
description: "Dative case of 'phone', i.e. 'phone' in 'by phone'",
},
email: {
id: 'email_dative',
defaultMessage: 'email',
description: "Dative case of 'email', i.e. 'email' in 'by email'",
}
});
class ValidationView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
code: props.credCode || ''
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(e) {
this.setState({code: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.code && this.state.code.trim()) {
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
}
}
render() {
const { formatMessage } = this.props.intl;
const methods = {'email': formatMessage(messages.email), 'tel': formatMessage(messages.phone)};
let method = methods[this.props.credMethod] || this.props.credMethod;
return (
<div className="panel-form">
<div className="panel-form-row">
<label className="small" htmlFor="enter-confirmation-code">
<FormattedMessage id="enter_confirmation_code_prompt"
defaultMessage="Enter confirmation code sent to you by {method}:"
description="Request to enter confirmation code"
values={{method: method}} />
</label>
</div>
<div className="panel-form-row">
<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}
value={this.state.code} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(ValidationView);
-85
View File
@@ -1,85 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import FileProgress from './file-progress.jsx';
import { bytesToHumanSize } from '../lib/strformat.js';
export default class Attachment extends React.Component {
constructor(props) {
super(props);
this.state = {
downloader: null,
progress: 0
};
this.downloadFile = this.downloadFile.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
downloadFile(url, filename, mimetype) {
var downloader = this.props.tinode.getLargeFileHelper();
this.setState({downloader: downloader});
downloader.download(url, filename, mimetype, (loaded) => {
this.setState({progress: loaded / this.props.size});
}).then(() => {
this.setState({downloader: null, progress: 0});
}).catch((err) => {
if (err) {
this.props.onError("Error downloading file: " + err.message, 'err');
}
this.setState({downloader: null, progress: 0});
});
}
handleCancel() {
if (this.props.uploader) {
this.props.onCancelUpload();
} else if (this.state.downloader) {
this.state.downloader.cancel();
}
}
render() {
let filename = this.props.filename || 'file_attachment';
if (filename.length > 36) {
filename = filename.substr(0, 16) + '...' + filename.substr(-16);
}
let size = this.props.size > 0 ?
<span className="small gray">({bytesToHumanSize(this.props.size)})</span> :
null;
// Detect if the download URL is relative or absolute.
// If the URL is relative use LargeFileHelper to attach authentication
// credentials to the request.
let url, helperFunc;
if (!this.props.uploader && !this.state.downloader &&
!(/^(?:(?:[a-z]+:)?\/\/)/i.test(this.props.downloadUrl))) {
// Relative URL. Use download helper.
url = "javascript:;";
helperFunc = (e) => {
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
};
} else {
url = this.props.downloadUrl;
helperFunc = null;
}
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column">
<div>{filename} {size}</div>
{this.props.uploader || this.state.downloader ?
<FileProgress progress={this.props.uploader ? this.props.progress : this.state.progress}
onCancel={this.handleCancel} />
:
<div><a href={url} download={this.props.filename} onClick={helperFunc} >
<i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" />
</a></div>
}
</div>
</div>
);
}
};
-81
View File
@@ -1,81 +0,0 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
import { AVATAR_SIZE } from '../config.js';
import { imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
export default class AvatarUpload extends React.Component {
constructor(props) {
super(props);
this.state = {
dataUrl: props.avatar
};
this.handleFileUpload = this.handleFileUpload.bind(this);
this.handleClear = this.handleClear.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
if (prevState.dataUrl != nextProps.avatar) {
return {
dataUrl: nextProps.avatar
};
}
return null;
}
handleFileUpload(e) {
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
// Success
(base64bits, mime) => {
var du = makeImageUrl({data: base64bits, type: mime});
this.setState({dataUrl: du});
this.props.onImageChanged(du);
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleClear() {
this.props.onImageChanged(null);
}
render() {
// Randomize id value in case more than one AvatarUpload is shown
// at the same time.
var randId = "file-input-avatar-" + (Math.random() + '').substr(2);
return (
<div className="avatar-upload">
{this.props.readOnly || !this.state.dataUrl ?
null :
<a href="javascript:;" className="clear-avatar" onClick={this.handleClear}>
<i className="material-icons">clear</i>
</a>}
{this.state.dataUrl ?
<img src={this.state.dataUrl} className="preview" /> :
this.props.readOnly && this.props.uid ?
<div className="avatar-box">
<LetterTile
avatar={true}
topic={this.props.uid}
title={this.props.title} />
</div>
:
<div className="blank">{AVATAR_SIZE}&times;{AVATAR_SIZE}</div>}
{this.props.readOnly ? null :
<input type="file" id={randId} className="inputfile hidden"
accept="image/*" onChange={this.handleFileUpload} />}
{this.props.readOnly ? null :
<label htmlFor={randId} className="round">
<i className="material-icons">file_upload</i>
</label>}
</div>
);
}
};
-198
View File
@@ -1,198 +0,0 @@
// Single message, sent or received.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Drafty } from 'tinode-sdk'
import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
export default class ChatMessage extends React.Component {
constructor(props) {
super(props);
this.state = {
progress: 0
};
if (props.uploader) {
props.uploader.onProgress = this.handleProgress.bind(this);
}
this.handlePreviewImage = this.handlePreviewImage.bind(this);
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
}
handlePreviewImage(e) {
e.preventDefault();
this.props.onImagePreview({
url: e.target.src,
filename: e.target.title,
width: e.target.dataset.width,
height: e.target.dataset.height,
size: e.target.dataset.size,
type: e.target.dataset.mime
});
}
handleFormButtonClick(e) {
e.preventDefault();
const data = {
seq: this.props.seq
};
data.resp = {};
if (e.target.dataset.name) {
data.resp[e.target.dataset.name] = e.target.dataset.val ? e.target.dataset.val :
e.target.dataset.val === undefined ? 1 : '' + e.target.dataset.val;
}
if (e.target.dataset.act == 'url') {
data.ref = '' + e.target.dataset.ref;
}
const text = e.target.dataset.title || 'unknown';
this.props.onFormResponse(e.target.dataset.act, text, data);
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX });
}
handleProgress(ratio) {
this.setState({progress: ratio});
}
handleCancelUpload() {
this.props.uploader.cancel();
}
render() {
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ?
'bubble tip' : 'bubble';
const avatar = this.props.userAvatar || true;
const fullDisplay = (this.props.userFrom && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
let content = this.props.content;
const attachments = [];
if (this.props.mimeType == Drafty.getContentType()) {
Drafty.attachments(content, function(att, i) {
if (att.mime == 'application/json') {
// Don't show json objects as attachments.
// They are not meant for users.
return;
}
attachments.push(<Attachment
tinode={this.props.tinode}
downloadUrl={Drafty.getDownloadUrl(att)}
filename={att.name} uploader={Drafty.isUploading(att)}
mimetype={att.mime} size={Drafty.getEntitySize(att)}
progress={this.state.progress}
onCancelUpload={this.handleCancelUpload}
onError={this.props.onError}
key={i} />);
}, this);
content = React.createElement('span', null, Drafty.format(content, draftyFormatter, this));
} else if (typeof content != 'string') {
content = <span><i className="material-icons">error_outline</i> <i>
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when message is unreadable" />
</i></span>
}
return (
<li className={sideClass}>
{this.props.userFrom && this.props.response ?
<div className="avatar-box">
{fullDisplay ?
<LetterTile
topic={this.props.userFrom}
title={this.props.userName}
avatar={avatar} /> :
null}
</div> :
null}
<div>
<div className={bubbleClass}>
<div className="message-content">
{content}
{attachments}
<ReceivedMarker
timestamp={this.props.timestamp}
received={this.props.received} />
</div>
<span className="menuTrigger">
<a href="javascript:;" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span>
</div>
{fullDisplay ?
<div className="author">
<FormattedMessage id="user_not_found" defaultMessage="Not found"
description="In place of a user's full name when the user is not found.">{
(notFound) => {return this.props.userName || <i>{notFound}</i>}
}</FormattedMessage>
</div>
: null
}
</div>
</li>
);
}
};
// Convert Drafty object to a tree of React elements.
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
function draftyFormatter(style, data, values, key) {
let el = Drafty.tagName(style);
if (el) {
let attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch (style) {
case 'IM':
// Additional processing for images
if (data) {
attr.className = 'inline-image';
let dim = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false);
dim = dim || {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
if (attr.src) {
attr.onClick = this.handlePreviewImage;
attr.className += ' image-clickable';
} else {
attr.src = 'img/broken_image.png';
}
}
break;
case 'BN':
// Button
attr.onClick = this.handleFormButtonClick;
let inner = React.Children.map(values, (child) => {
return typeof child == 'string' ? child : undefined;
});
if (!inner || inner.length == 0) {
inner = [attr.name]
}
// Get text which will be sent back when the button is clicked.
attr['data-title'] = inner.join('');
break;
case 'FM':
// Form
attr.className = 'bot-form';
break;
case 'FE':
// Form element formatting is dependent on element content.
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
/* BEGIN CheckBox: styled checkbox */
export default class CheckBox extends React.PureComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange() {
this.props.onChange(this.props.name, !this.props.checked);
}
render() {
return (
this.props.onChange ? (
this.props.checked ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i>
) : (
this.props.checked ?
<i className="material-icons">check_box</i> :
<i className="material-icons">check_box_outline_blank</i>
)
);
}
}
/* END CheckBox */
-142
View File
@@ -1,142 +0,0 @@
import React from 'react';
import Chip from './chip.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
/* BEGIN ChipInput: group membership widget */
export default class ChipInput extends React.Component {
constructor(props) {
super(props);
this.state = ChipInput.getDerivedStateFromProps(props);
this.handleTextInput = this.handleTextInput.bind(this);
this.removeChipAt = this.removeChipAt.bind(this);
this.handleChipCancel = this.handleChipCancel.bind(this);
this.handleFocusGained = this.handleFocusGained.bind(this);
this.handleFocusLost = this.handleFocusLost.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
const state = {
placeholder: nextProps.chips ? '' : nextProps.prompt,
sortedChips: ChipInput.sortChips(nextProps.chips, nextProps.required),
chipIndex: ChipInput.indexChips(nextProps.chips),
focused: prevState && prevState.focused
};
if (!prevState || nextProps.chips.length > prevState.sortedChips.length) {
// Chip added: clear input.
state.input ='';
}
return state;
}
// Map chip index to user name
static indexChips(chips) {
const index = {};
let count = 0;
chips.map(function(item) {
index[item.user] = count;
count ++;
});
return index;
}
// Have non-removable chips appear before all other chips.
static sortChips(chips, keep) {
const required = [];
const normal = [];
chips.map(function(item) {
if (item.user === keep) {
required.push(item);
} else {
normal.push(item);
}
});
return required.concat(normal);
}
handleTextInput(e) {
this.setState({input: e.target.value});
if (this.props.filterFunc) {
this.props.filterFunc(e.target.value);
}
}
removeChipAt(idx) {
const removed = this.state.sortedChips[idx];
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
}
handleChipCancel(item, idx) {
this.removeChipAt(idx);
}
handleFocusGained() {
this.setState({focused: true});
}
handleFocusLost() {
this.setState({focused: false});
if (this.props.onFocusLost) {
this.props.onFocusLost(this.state.input);
}
}
handleKeyDown(e) {
if (e.key === 'Backspace') {
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
var at = this.state.sortedChips.length - 1;
if (this.state.sortedChips[at].user !== this.props.required) {
this.removeChipAt(at);
}
}
} else if (e.key === 'Enter') {
if (this.props.onEnter) {
this.props.onEnter(this.state.input);
}
} else if (e.key === 'Escape') {
if (this.props.onCancel) {
this.props.onCancel();
}
}
}
render() {
const chips = [];
let count = 0;
this.state.sortedChips.map((item) => {
chips.push(
<Chip
onCancel={this.handleChipCancel}
avatar={makeImageUrl(item.public ? item.public.photo : null)}
title={item.public ? item.public.fn : undefined}
noAvatar={this.props.avatarDisabled}
topic={item.user}
required={item.user === this.props.required}
invalid={item.invalid}
index={count}
key={item.user} />
);
count++;
});
const className = "chip-input" + (this.state.focused ? ' focused' : '');
return (
<div className={className}>
{chips}
<input type="text"
placeholder={this.state.placeholder}
onChange={this.handleTextInput}
onFocus={this.handleFocusGained}
onBlur={this.handleFocusLost}
onKeyDown={this.handleKeyDown}
value={this.state.input}
autoFocus />
</div>
);
}
};
-37
View File
@@ -1,37 +0,0 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
export default class Chip extends React.PureComponent {
constructor(props) {
super(props);
this.handleCancel = this.handleCancel.bind(this);
}
handleCancel() {
this.props.onCancel(this.props.topic, this.props.index);
}
render() {
const title = this.props.title || this.props.topic;
const className = this.props.invalid ? 'chip invalid' : 'chip';
return (
<div className={className}>
{this.props.noAvatar ?
<span className="spacer" /> :
<div className="avatar-box">
<LetterTile
avatar={this.props.avatar || true}
topic={this.props.topic}
title={this.props.title} />
</div>
}
<span>{title}</span>
{this.props.onCancel && !this.props.required ?
<a href="javascript:;" onClick={this.handleCancel} >&times;</a>
: <span className="spacer" />}
</div>
);
}
};
-28
View File
@@ -1,28 +0,0 @@
// A panel included into a list of contacts with an action text.
import React from 'react';
import { injectIntl } from 'react-intl';
class ContactAction extends React.PureComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.onAction(this.props.action);
}
render() {
const {formatMessage} = this.props.intl;
return (
<li onClick={this.handleClick} className="action">
<div className="action-text">{formatMessage(this.props.title, this.props.values)}</div>
</li>
);
}
};
export default injectIntl(ContactAction);
-23
View File
@@ -1,23 +0,0 @@
import React from 'react';
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block'};
/* Contact's labels: [you], or icons [muted], [blocked] */
export default class ContactBadges extends React.PureComponent {
render() {
let badges = null;
if (this.props.badges && this.props.badges.length > 0) {
badges = [];
this.props.badges.map(function(b) {
if (b.icon) {
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>);
} else {
const style = 'badge' + (b.color ? ' ' + b.color : '');
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
}
});
return <React.Fragment>{badges}</React.Fragment>;
}
return null;
}
};
-117
View File
@@ -1,117 +0,0 @@
// ContactList: component for showing a list of contacts,
// such as a list of group members in a group chat.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
const messages = defineMessages({
badge_you: {
id: 'badge_you',
defaultMessage: 'you',
description: 'Badge for indicating the current user'
},
badge_owner: {
id: 'badge_owner',
defaultMessage: 'owner',
description: 'Badge for indicating the owner'
}
});
class ContactList extends React.Component {
render() {
const {formatMessage} = this.props.intl;
const showCheckmark = Array.isArray(this.props.topicSelected);
const contactNodes = [];
let contactsCount = 0;
if (this.props.contacts && this.props.contacts.length > 0) {
this.props.contacts.map((c) => {
if (c.action) {
// Action item
contactNodes.push(
<ContactAction
title={c.title} action={c.action} values={c.values}
key={c.action}
onAction={this.props.onAction}
/>);
} else {
// Normal contact
const key = c.topic ? c.topic : c.user;
// If filter function is provided, filter out the items
// which don't satisfy the condition.
if (this.props.filterFunc && this.props.filter) {
let content = [key];
if (c.private && c.private.comment) {
content.push(('' + c.private.comment).toLowerCase());
}
if (c.public && c.public.fn) {
content.push(('' + c.public.fn).toLowerCase());
}
if (!this.props.filterFunc(this.props.filter, content)) {
return;
}
}
const selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
const badges = [];
if (this.props.showMode) {
if (key === this.props.myUserId) {
badges.push({name: formatMessage(messages.badge_you), color: 'green'});
}
if (c.acs && c.acs.isOwner()) {
badges.push({name: formatMessage(messages.badge_owner), color: 'blue'});
}
}
const comment = Array.isArray(c.private) ?
c.private.join(',') : (c.private ? c.private.comment : null);
contactNodes.push(
<Contact
title={c.public ? c.public.fn : null}
avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment}
unread={this.props.showUnread ? c.unread : 0}
now={c.online && this.props.connected}
acs={c.acs}
showMode={this.props.showMode}
badges={badges}
showCheckmark={showCheckmark}
selected={selected}
showOnline={this.props.showOnline}
onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
item={key}
index={contactNodes.length}
key={key} />
);
contactsCount ++;
}
}, this);
}
return (
<div className={this.props.noScroll ? null : "scrollable-panel"}>
{contactsCount == 0 ?
<div className="center-medium-text"
dangerouslySetInnerHTML={{__html: this.props.emptyListMessage}} />
:
null
}
{contactNodes.length > 0 ?
<ul className="contact-box">
{contactNodes}
</ul>
:
null
}
</div>
);
}
};
export default injectIntl(ContactList);
-84
View File
@@ -1,84 +0,0 @@
// A single topic or user.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import LetterTile from './letter-tile.jsx';
import ContactBadges from './contact-badges.jsx';
import UnreadBadge from './unread-badge.jsx';
export default class Contact extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
if (this.props.onSelected) {
this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs);
}
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
}
render() {
let title = this.props.title;
if (!title) {
title = <i><FormattedMessage id="unnamed_topic" /></i>;
} else if (title.length > 30) {
// FIXME: this is probably wrong for RTL languages.
title = title.substring(0, 28) + '...';
}
const online = this.props.now ? 'online' : 'offline';
const avatar = this.props.avatar ? this.props.avatar : true;
const badges = this.props.badges ? this.props.badges.slice() : [];
const icon_badges = [];
if (this.props.acs) {
if (this.props.showMode) {
badges.push({name: this.props.acs.getMode(), key: 'mode'});
}
if (this.props.acs.isMuted()) {
icon_badges.push({icon: 'muted'});
}
if (!this.props.acs.isJoiner()) {
icon_badges.push({icon: 'banned'});
}
}
return (
<li className={!this.props.showCheckmark && this.props.selected ? "selected" : null}
onClick={this.handleClick}>
<div className="avatar-box">
<LetterTile
avatar={avatar}
title={this.props.title}
topic={this.props.item} />
{this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected ?
<i className="checkmark material-icons">check_circle</i>
: null)}
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
</div>
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
<span><ContactBadges badges={badges} /></span>
</div>
{this.props.showContextMenu ?
<span className="menuTrigger">
<a href="javascript:;" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span> : null}
</li>
);
}
};
-361
View File
@@ -1,361 +0,0 @@
// Context Menu: popup/dropdown menu.
import React from 'react';
import ReactDOM from 'react-dom';
import { injectIntl, defineMessages } from 'react-intl';
import { REM_SIZE } from '../config.js';
import HashNavigation from '../lib/navigation.js';
const messages = defineMessages({
info: {
id: 'menu_item_info',
defaultMessage: 'Info',
description: 'Show extended topic information'
},
clear_messages: {
id: 'menu_item_clear_messages',
defaultMessage: 'Clear messages',
description: 'Delete all messages'
},
clear_for_all: {
id: 'menu_item_clear_messages_for_all',
defaultMessage: 'Clear for All',
description: 'Delete all message(s) for all members'
},
'delete': {
id: 'menu_item_delete',
defaultMessage: 'Delete',
description: 'Delete selected messages'
},
delete_for_all: {
id: 'menu_item_delete_for_all',
defaultMessage: 'Delete for All',
description: 'Delete selected message(s) for all members'
},
mute: {
id: 'menu_item_mute',
defaultMessage: 'Mute',
description: 'Turn off notifications'
},
unmute: {
id: 'menu_item_unmute',
defaultMessage: 'Unmute',
description: 'Turn notifications on'
},
topic_delete: {
id: 'menu_item_delete_topic',
defaultMessage: 'Delete',
description: 'Delete entire topic'
},
unblock: {
id: 'menu_item_unblock',
defaultMessage: 'Unblock',
description: 'Unblock user'
},
block: {
id: 'menu_item_block',
defaultMessage: 'Block',
description: 'Block user'
},
member_delete: {
id: 'menu_item_member_delete',
defaultMessage: 'Remove',
description: 'Remove user from topic'
},
archive: {
id: 'menu_item_archive_topic',
defaultMessage: 'Archive',
description: 'Move topic from the list of active chats to archive'
}
});
class ContextMenu extends React.Component {
constructor(props) {
super(props);
const {formatMessage} = props.intl;
this.handlePageClick = this.handlePageClick.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClick = this.handleClick.bind(this);
// Preconfigured menu items.
this.MenuItems = {
'topic_info': {
id: 'topic_info',
title: formatMessage(messages.info),
handler: null
},
'messages_clear': {
id: 'messages_clear',
title: formatMessage(messages.clear_messages),
handler: (params, errorHandler) => {
return this.deleteMessages(true, false, params, errorHandler);
}
},
'messages_clear_hard': {
id: 'messages_clear_hard',
title: formatMessage(messages.clear_for_all),
handler: (params, errorHandler) => {
return this.deleteMessages(true, true, params, errorHandler);
}
},
'message_delete': {
id: 'message_delete',
title: formatMessage(messages.delete),
handler: (params, errorHandler) => {
return this.deleteMessages(false, false, params, errorHandler);
}
},
'message_delete_hard': {
id: 'message_delete_hard',
title: formatMessage(messages.delete_for_all),
handler: (params, errorHandler) => {
return this.deleteMessages(false, true, params, errorHandler);
}
},
'topic_unmute': {
id: 'topic_unmute',
title: formatMessage(messages.unmute),
handler: this.topicPermissionSetter.bind(this, '+P')
},
'topic_mute': {
id: 'topic_mute',
title: formatMessage(messages.mute),
handler: this.topicPermissionSetter.bind(this, '-P')
},
'topic_unblock': {
id: 'topic_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+JP')
},
'topic_block': {
id: 'topic_block',
title: formatMessage(messages.block),
handler: (params, errorHandler) => {
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
this.props.onTopicRemoved(params.topicName);
return ctrl;
});
}
},
'topic_delete': {
id: 'topic_delete',
title: formatMessage(messages.topic_delete),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.delTopic().catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
'topic_archive': {
id: 'topic_archive',
title: formatMessage(messages.archive),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.archive(true).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
// menu_item_edit_permissions is defined elsewhere.
'permissions': {
id: 'permissions',
title: formatMessage({id: 'menu_item_edit_permissions'}),
handler: null
},
'member_delete': {
id: 'member_delete',
title: formatMessage(messages.member_delete),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
return topic.delSubscription(params.user).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
'member_mute': {
id: 'member_mute',
title: formatMessage(messages.mute),
handler: this.topicPermissionSetter.bind(this, '-P')
},
'member_unmute': {
id: 'member_unmute',
title: formatMessage(messages.unmute),
handler: this.topicPermissionSetter.bind(this, '+P')
},
'member_block': {
id: 'member_block',
title: formatMessage(messages.block),
handler: this.topicPermissionSetter.bind(this, '-J')
},
'member_unblock': {
id: 'member_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+J')
},
};
}
componentDidMount() {
document.addEventListener('mousedown', this.handlePageClick, false);
document.addEventListener('keyup', this.handleEscapeKey, false);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.handlePageClick, false);
document.removeEventListener('keyup', this.handleEscapeKey, false);
}
handlePageClick(e) {
if (ReactDOM.findDOMNode(this).contains(e.target)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.props.hide();
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
this.props.hide();
}
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.hide();
let item = this.props.items[e.currentTarget.dataset.id];
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (!item) {
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
} else {
this.props.onAction(
item.id,
item.handler(this.props.params, this.props.onError),
this.props.params);
}
}
// Menu Actions
deleteMessages(all, hard, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
// We don't know if the message is still pending (e.g. attachment is being uploaded),
// so try cancelling first. No harm if we can't cancel.
// The message can be cancelled if transmission to the server has not
// started yet or if the message send has failed.
if (!all && topic.cancelSend(params.seq)) {
return;
}
// Can't cancel. Delete instead.
let promise = all ?
topic.delMessagesAll(hard) :
topic.delMessagesList([params.seq], hard);
return promise.catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Function is used by context menu to set permissions.
topicPermissionSetter(mode, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found", params.topicName);
return;
}
let am, user;
if (params.user) {
user = topic.subscriber(params.user);
if (!user) {
console.log("Subscriber not found", params.topicName + "[" + params.user + "]");
return;
}
am = user.acs.updateGiven(mode).getGiven();
} else {
am = topic.getAccessMode().updateWant(mode).getWant();
}
let result = topic.setMeta({sub: {user: params.user, mode: am}});
if (errorHandler) {
result = result.catch((err) => {
errorHandler(err.message, 'err');
});
}
return result;
}
render() {
let count = 0;
let menu = [];
this.props.items.map((item) => {
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (item && item.title) {
menu.push(
item.title == '-' ?
<li className="separator" key={count} />
:
<li onClick={this.handleClick} data-id={count} key={count}>{item.title}</li>
);
}
count++;
});
// Ensure that menu is inside the app-container.
const hSize = 12 * REM_SIZE;
const vSize = REM_SIZE * (0.7 + menu.length * 2.5);
const left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
(this.props.clickAt.x - this.props.bounds.left - hSize) :
(this.props.clickAt.x - this.props.bounds.left);
const top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
(this.props.clickAt.y - this.props.bounds.top - vSize) :
(this.props.clickAt.y - this.props.bounds.top);
const position = {
left: left + 'px',
top: top + 'px'
};
return (
<ul className="menu" style={position}>
{menu}
</ul>
);
}
}
export default injectIntl(ContextMenu);
-48
View File
@@ -1,48 +0,0 @@
import React from 'react';
import MenuCancel from './menu-cancel.jsx';
export default class ErrorPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
show: false,
};
this.hide = this.hide.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
return {
show: !(!nextProps.level)
};
}
hide() {
this.setState({show: false});
if (this.props.onClearError) {
this.props.onClearError();
}
}
render() {
const icons = {err: 'error', warn: 'warning', info: 'info'}
const level = icons[this.props.level] || '';
const className = 'alert-box ' + level;
return (
<div className={className}>
<div className="icon"><i className="material-icons">{level}</i></div>
<span>
<span dangerouslySetInnerHTML={{__html: this.props.text}} />
{this.props.action ?
<a href="javascript:;" onClick={this.props.action}>
{this.props.actionText}
</a>
: null}
</span>
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
</div>
);
}
};
-22
View File
@@ -1,22 +0,0 @@
// File uload/download progress indicator with a cancel inside.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class FileProgress extends React.PureComponent {
render() {
return (
<div className="uploader">
<div><span style={{width: (this.props.progress * 100) + "%"}}></span></div>
{this.props.progress < 0.999 ?
<a href="javascript:;" onClick={this.props.onCancel}>
<i className="material-icons">close</i> <FormattedMessage id="action_cancel"
defaultMessage="cancel" description="Call to action [cancel]" />
</a>
:
<FormattedMessage id="upload_finishing" defaultMessage="finishing..."
description="Notification that upload is finishing" />
}
</div>
);
}
}
-191
View File
@@ -1,191 +0,0 @@
// GroupMembers: control for managing a list of group members.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import ChipInput from './chip-input.jsx';
import ContactList from './contact-list.jsx';
const messages = defineMessages({
no_contacts: {
id: 'no_contacts',
defaultMessage: 'You have no contacts :-(',
description: 'Shown in ContactsView when the user has no contacts'
},
contacts_not_found: {
id: 'contacts_not_found_short',
defaultMessage: "No contacts match '{query}'",
description: 'Shown in ContactsView when search returned no results'
}
});
class GroupManager extends React.Component {
constructor(props) {
super(props);
this.state = {
members: props.members,
index: GroupManager.indexMembers(props.members),
contactFilter: '',
noContactsMessage: props.intl.formatMessage(messages.no_contacts),
selectedContacts: GroupManager.selectedContacts(props.members)
};
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
this.handleContactFilter = this.handleContactFilter.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static indexMembers(members) {
let index = {};
members.map(function(m) {
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
});
return index;
}
static selectedContacts(members) {
let sel = [];
members.map(function(m) {
sel.push(m.user);
});
return sel;
}
handleContactSelected(userId, index) {
let status = this.state.index[userId];
if (status) {
if (status.present) {
// Prevent duplicate members
return;
}
status.delta += 1;
status.present = true;
} else {
status = {delta: 1, present: true};
}
var m = this.state.members.slice();
m.push(this.props.contacts[index]);
var sel = GroupManager.selectedContacts(m);
var i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleMemberRemoved(userId, index) {
var status = this.state.index[userId];
if (!status || !status.present) {
return;
}
status.present = false;
status.delta -= 1;
var m = this.state.members.slice();
m.splice(index, 1);
var sel = GroupManager.selectedContacts(m);
var i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleContactFilter(val) {
const {formatMessage} = this.props.intl;
const msg = !val ?
formatMessage(messages.no_contacts) :
formatMessage(messages.contacts_not_found, {query: val});
this.setState({contactFilter: val, noContactsMessage: msg});
}
static doContactFiltering(filter, values) {
if (filter) {
for (var i=0; i<values.length; i++) {
if (values[i].indexOf(filter) >= 0) {
return true;
}
}
return false;
}
return true;
}
handleSubmit() {
var instance = this;
var members = [];
var added = [];
var removed = [];
var keys = Object.keys(this.state.index);
keys.map(function(k) {
if (instance.state.index[k].present) {
members.push(k);
}
if (instance.state.index[k].delta > 0) {
added.push(k);
} else if (instance.state.index[k].delta < 0) {
removed.push(k);
}
});
this.props.onSubmit(members, added, removed);
}
handleCancel() {
this.props.onCancel();
}
render() {
const {formatMessage} = this.props.intl;
return (
<div id="group-manager">
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="title_group_members" defaultMessage="Group Members" description="Section title" />
</label>
</div>
<div className="panel-form-row">
<ChipInput
chips={this.state.members}
required={this.props.requiredMember}
prompt="add members"
filterFunc={this.handleContactFilter}
onChipRemoved={this.handleMemberRemoved} />
</div>
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="title_all_contacts" defaultMessage="All Contacts"
description="Section title [All Contacts]" />
</label>
</div>
<ContactList
contacts={this.props.contacts}
myUserId={this.props.myUserId}
topicSelected={this.state.selectedContacts}
filter={this.state.contactFilter}
filterFunc={GroupManager.doContactFiltering}
emptyListMessage={this.state.noContactsMessage}
showOnline={false}
showUnread={false}
onTopicSelected={this.handleContactSelected} />
<div id="group-manager-buttons" className="panel-form-row">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
<button className="white" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(GroupManager);
-43
View File
@@ -1,43 +0,0 @@
// GroupSubs: a list of group subscribers currently online.
// Show in the topic title bar
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
import LetterTile from './letter-tile.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
export default class GroupSubs extends React.Component {
constructor(props) {
super(props);
}
render() {
const usersOnline = [];
const totalCount = (this.props.subscribers || []).length;
const countToShow = Math.min(MAX_ONLINE_IN_TOPIC, totalCount);
(this.props.subscribers || []).some((sub) => {
usersOnline.push(
<div className="avatar-box" key={sub.user}>
<LetterTile
topic={sub.user}
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
title={sub.public ? sub.public.fn : null} />
</div>
);
return usersOnline.length == countToShow;
});
return (
<div id="topic-users">{usersOnline} {totalCount > countToShow ?
<span>
<FormattedMessage id="more_online_members" defaultMessage="+{overflow} more"
description="Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
values={{ overflow: (totalCount - countToShow) }} />
</span> : null}
</div>
);
}
};
-50
View File
@@ -1,50 +0,0 @@
import React from 'react';
import { KNOWN_HOSTS } from '../config.js';
/* Combobox for selecting host name */
export default class HostSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hostName: props.serverAddress,
changed: false
};
this.handleHostNameChange = this.handleHostNameChange.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handleHostNameChange(e) {
this.setState({hostName: e.target.value, changed: true});
}
handleEditingFinished() {
if (this.state.changed) {
this.setState({changed: false});
this.props.onServerAddressChange(this.state.hostName.trim());
}
}
render() {
var hostOptions = [];
for (let key in KNOWN_HOSTS) {
let item = KNOWN_HOSTS[key];
hostOptions.push(
<option key={item} value={item} />
);
}
return (
<div className="panel-form-row">
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
className="quoted" value={this.state.hostName} onChange={this.handleHostNameChange}
onBlur={this.handleEditingFinished} required />
<datalist id="known-hosts">
{hostOptions}
</datalist>
</div>
);
}
}
-73
View File
@@ -1,73 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
import { bytesToHumanSize } from '../lib/strformat.js';
export default class ImagePreview extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this.setState({
width: this.container.clientWidth,
height: this.container.clientHeight
});
}
render() {
if (!this.props.content) {
return null;
}
const instance = this;
const dim = fitImageSize(this.props.content.width, this.props.content.height,
this.state.width, this.state.height, false);
const size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
size.maxWidth = '100%';
size.maxHeight = '100%';
let filename = this.props.content.filename;
const maxlength = (this.props.content.width / REM_SIZE) | 0;
if (filename.length > maxlength) {
filename = filename.slice(0, maxlength-2) + '...' + filename.slice(2-maxlength);
}
const width = this.props.content.width || '-';
const height = this.props.content.height || '-';
return (
<div id="image-preview" onClick={this.props.onClose}>
<div id="image-preview-caption-panel">
<a href={this.props.content.url} download={this.props.content.filename}>
<i className="material-icons">file_download</i> <FormattedMessage
id="download_action" defaultMessage="download" description="Call to action [download]" />
</a>
<a href="javascript:;" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container" ref={function(ref) {instance.container = ref;}}>
<img src={this.props.content.url} style={size} />
</div>
<div id="image-preview-footer">
<div>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b></div>
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
</div>
<div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
description="Label for file content type (mime)" /></b></div>
<div>{this.props.content.type}</div>
</div>
<div>
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
description="Label for file size" /></b></div>
<div>{width} &times; {height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>
</div>
);
}
};
-120
View File
@@ -1,120 +0,0 @@
// In-place text editor. Shows text with an icon which toggles it into an input field.
import React from 'react';
import ReactDOM from 'react-dom';
import VisiblePassword from './visible-password.jsx';
export default class InPlaceEdit extends React.Component {
constructor(props) {
super(props);
this.state = {
active: props.active,
initialValue: props.value || '',
value: props.value || ''
};
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEditing = this.handleStartEditing.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
this.handlePasswordFinished = this.handlePasswordFinished.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
// If text has changed while in read mode, update text and discard changes.
// Ignore update if in edit mode.
if (prevState.initialValue != nextProps.value && !prevState.active) {
return {
initialValue: nextProps.value || '',
value: nextProps.value || ''
};
}
return null;
}
handeTextChange(e) {
this.setState({value: e.target.value});
}
handleKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({value: this.props.value, active: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleEditingFinished(e);
}
}
handleStartEditing() {
if (!this.props.readOnly) {
ReactDOM.findDOMNode(this).focus();
this.setState({active: true});
}
}
handleEditingFinished(event) {
if (this.props.required && !event.target.checkValidity()) {
// Empty input
this.setState({value: this.props.value, active: false});
return;
}
this.setState({active: false});
let value = this.state.value.trim();
if ((value || this.props.value) && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
handlePasswordFinished(value) {
this.setState({active: false});
if (value && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
render() {
if (this.state.active) {
var fieldType = this.props.type || 'text';
} else {
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
var spanClass = 'in-place-edit' +
(this.props.readOnly ? ' disabled' : '');
if (!spanText) {
spanText = this.props.placeholder;
spanClass += ' placeholder';
}
if (spanText.length > 20) {
// FIXME: this is wrong for RTL languages.
spanText = spanText.substring(0, 19) + '...';
}
}
return (
this.state.active ?
(fieldType == 'password' ?
<VisiblePassword
value={this.state.value}
placeholder={this.props.placeholder}
required={this.props.required ? 'required' : ''}
autoComplete={this.props.autoComplete}
autoFocus={true}
onFinished={this.handlePasswordFinished}/>
:
<input type={fieldType}
value={this.state.value}
placeholder={this.props.placeholder}
required={this.props.required ? 'required' : ''}
autoComplete={this.props.autoComplete}
autoFocus
onChange={this.handeTextChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleEditingFinished} />
)
:
<span className={spanClass} onClick={this.handleStartEditing}>
<span className="content">{spanText}</span>
</span>
);
}
};
-42
View File
@@ -1,42 +0,0 @@
// A single topic or user.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class Invitation extends React.PureComponent {
constructor(props) {
super(props);
this.handleButtonAction = this.handleButtonAction.bind(this);
}
handleButtonAction(evt, data) {
evt.preventDefault();
this.props.onAction(data);
}
render() {
return (
<div className="accept-invite-panel">
<div className="title">
<FormattedMessage id="chat_invitation"
defaultMessage="You are invited to start a new chat. What would you like to do?"
description="New chat invitation message: [Accept] [Ignore] [Block]." />
</div>
<div className="footer">
<button className="blue" onClick={event => { this.handleButtonAction(event, "accept"); }}>
<FormattedMessage id="chat_invitation_accept"
defaultMessage="Accept" description="Action [Accept] for chat invitation." />
</button>
<button className="white" onClick={event => { this.handleButtonAction(event, "delete"); }}>
<FormattedMessage id="chat_invitation_ignore"
defaultMessage="Ignore" description="Action [Ignore] for chat invitation." />
</button>
<button className="white" onClick={event => { this.handleButtonAction(event, "block"); }}>
<FormattedMessage id="chat_invitation_block"
defaultMessage="Block" description="Action [Block] for chat invitation." />
</button>
</div>
</div>
);
}
};
-32
View File
@@ -1,32 +0,0 @@
import React from 'react';
import Tinode from 'tinode-sdk';
import { stringHash } from '../lib/strformat.js';
export default class LetterTile extends React.PureComponent {
render() {
let avatar;
if (this.props.avatar === true) {
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
if (this.props.topic && this.props.title && this.props.title.trim()) {
const letter = this.props.title.trim().charAt(0);
const color = 'lettertile '
+ (isGroup ? 'color' : 'dark-color')
+ (Math.abs(stringHash(this.props.topic)) % 16);
avatar = (<div className={color}><div>{letter}</div></div>)
} else {
avatar = isGroup ?
<i className="material-icons">group</i> :
<i className="material-icons">person</i>;
}
} else if (this.props.avatar) {
// If avatar image is invalid, show a placeholder.
avatar = <img className="avatar" alt="avatar" src={this.props.avatar}
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />;
} else {
avatar = null;
}
return avatar;
}
}
-8
View File
@@ -1,8 +0,0 @@
import React from 'react';
export default class LoadSpinner extends React.PureComponent {
render() {
return (this.props.show ?
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null);
}
}
-14
View File
@@ -1,14 +0,0 @@
// The X menu to be displayed in title bars.
import React from 'react';
export default class MenuCancel extends React.PureComponent {
constructor(props) {
super(props);
}
render() {
return (
<a href="javascript:;" onClick={this.props.onCancel}><i className="material-icons">close</i></a>
);
}
}
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
export default class MenuContacts extends React.PureComponent {
render() {
return (
<div>
<a href="javascript:;" onClick={this.props.onNewTopic}><i className="material-icons">chat</i></a>
&nbsp;
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
</div>
);
}
};
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
export default class MenuStart extends React.PureComponent {
render() {
return (
<div>
<a href="javascript:;" onClick={this.props.onSignUp}><i className="material-icons">person_add</i></a>
&nbsp;
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
</div>
);
}
};
-29
View File
@@ -1,29 +0,0 @@
// Toggle [Title text >] -> [Title text v]
import React from 'react';
export default class MoreButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
open: props.open
};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle() {
const open = !this.state.open;
this.setState({open: open});
if (this.props.onToggle) {
this.props.onToggle(open);
}
}
render() {
return (<label className="small clean-clickable" onClick={this.handleToggle}>{this.props.title}...
{this.state.open ? <i className="material-icons">expand_more</i> :
<i className="material-icons">chevron_right</i>}
</label>);
}
}
-69
View File
@@ -1,69 +0,0 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
invalid_id: {
id: 'error_invalid_id',
defaultMessage: 'Invalid ID',
description: 'Error message'
}
});
class NewTopicById extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
groupId: '',
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(e) {
this.setState({groupId: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.groupId) {
var name = this.state.groupId.trim();
if (name.length > 3 && (name.substr(0, 3) == 'usr' || name.substr(0, 3) == 'grp')) {
this.props.onSubmit(name);
} else {
this.props.onError(this.props.intl.formatMessage(messages.invalid_id), 'err');
}
}
}
render() {
return (
<div className="panel-form">
<div className="panel-form-row">
<FormattedMessage id="group_user_id_prompt" defaultMessage="Group or User ID"
description="Prompt for entering user or group ID">{
(prompt) => <input type="text" placeholder={prompt}
value={this.state.groupId} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_subscribe" defaultMessage="Subscribe"
description="Button [Subscribe]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(NewTopicById);
-97
View File
@@ -1,97 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from './avatar-upload.jsx';
import TagManager from './tag-manager.jsx';
export default class NewTopicGroup extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
fn: '', // full/formatted name
private: '',
imageDataUrl: null,
tags: []
};
this.handleFnChange = this.handleFnChange.bind(this);
this.handlePrivateChange = this.handlePrivateChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handlePrivateChange(e) {
this.setState({private: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleTagsChanged(tags) {
this.setState({tags: tags});
}
handleSubmit(e) {
e.preventDefault();
if (this.state.fn && this.state.fn.trim()) {
this.props.onSubmit(this.state.fn.trim(),
this.state.imageDataUrl, this.state.private.trim(),
this.state.tags);
}
}
render() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<label className="small" htmlFor="new-topic-fn">
<FormattedMessage id="label_topic_name" />
</label>
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
description="Prompt for entering topic name">{
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
}</FormattedMessage>
<br />
<label className="small" htmlFor="new-topic-priv">
<FormattedMessage id="label_private" />
</label>
<FormattedMessage id="private_editing_placeholder">{
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
value={this.state.private} onChange={this.handlePrivateChange} />
}</FormattedMessage>
</div>
<AvatarUpload
onError={this.props.onError}
onImageChanged={this.handleImageChanged} />
</div>
<FormattedMessage id="title_tag_manager">{
(title) => <TagManager
tags={this.state.tags}
activated={true}
onTagsChanged={this.handleTagsChanged}
title={title} />
}</FormattedMessage>
<div className="dialog-buttons">
<button className={submitClasses}>
<FormattedMessage id="button_create" defaultMessage="Create"
description="Button [Create]" />
</button>
</div>
</form>
);
}
};
-165
View File
@@ -1,165 +0,0 @@
// PermissionsEditor: Component for editing permissions
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import CheckBox from './checkbox.jsx';
import Contact from './contact.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
// Translatable permission names.
const messages = defineMessages({
'joiner': {
id: 'permission_join',
defaultMessage: "Join ({val})",
description: 'Name of J permission'
},
'reader': {
id: 'permission_read',
defaultMessage: "Read ({val})",
description: 'Name of R permission'
},
'writer': {
id: 'permission_write',
defaultMessage: "Write ({val})",
description: 'Name of W permission'
},
'preser': {
id: 'permission_pres',
defaultMessage: "Get notified ({val})",
description: 'Name of P permission'
},
'approver': {
id: 'permission_admin',
defaultMessage: "Approve ({val})",
description: 'Name of A permission'
},
'sharer': {
id: 'permission_share',
defaultMessage: "Share ({val})",
description: 'Name of S permission'
},
'deleter': {
id: 'permission_delete',
defaultMessage: "Delete ({val})",
description: 'Name of D permission'
},
'owner': {
id: 'permission_owner',
defaultMessage: "Owner ({val})",
description: 'Name of O permission'
}
});
class PermissionsEditor extends React.Component {
constructor(props) {
super(props);
this.state = {
mode: (props.mode || '').replace('N', '')
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleChange(val) {
let mode = this.state.mode;
let idx = mode.indexOf(val);
if (idx == -1) {
mode += val;
} else {
mode = mode.replace(val, '');
}
this.setState({mode: mode});
}
handleSubmit() {
// Normalize string, otherwise cannot check if mode has changed.
var mode = (this.state.mode || 'N').split('').sort().join('');
var before = (this.props.mode || 'N').split('').sort().join('')
if (mode !== before) {
this.props.onSubmit(mode);
} else {
this.props.onCancel();
}
}
handleCancel() {
this.props.onCancel();
}
render() {
const {formatMessage} = this.props.intl;
const all = 'JRWPASDO';
const names = {
'J': formatMessage(messages.joiner, {val: 'J'}),
'R': formatMessage(messages.reader, {val: 'R'}),
'W': formatMessage(messages.writer, {val: 'W'}),
'P': formatMessage(messages.preser, {val: 'P'}),
'A': formatMessage(messages.approver, {val: 'A'}),
'S': formatMessage(messages.sharer, {val: 'S'}),
'D': formatMessage(messages.deleter, {val: 'D'}),
'O': formatMessage(messages.owner, {val: 'O'})
};
let skip = this.props.skip || '';
let mode = this.state.mode;
let compare = (this.props.compare || '').replace('N', '');
let items = [];
for (let i=0; i<all.length; i++) {
let c = all.charAt(i);
if (skip.indexOf(c) >= 0 && mode.indexOf(c) < 0) {
// Permission is marked as inactive: hide unchecked permissions, disable checked permissions
continue;
}
items.push(
<tr key={c}>
<td>{names[c]}</td>
<td className="checkbox">{skip.indexOf(c) < 0 ?
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} onChange={this.handleChange}/>
:
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} />
}</td>{this.props.compare ? <td className="checkbox">
<CheckBox name={c} checked={(compare.indexOf(c) >= 0)}/>
</td> : null}
</tr>
);
}
return (
<div className="panel-form-column">
{this.props.userTitle ?
<ul className="contact-box"><Contact
item={this.props.item}
title={this.props.userTitle}
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
<label className="small"><FormattedMessage id="title_permissions"
defaultMessage="Permissions" description="Section title"/></label>
<table className="permission-editor">
{this.props.compare ?
<thead><tr>
<th></th><th>{this.props.modeTitle}</th>
<th>{this.props.compareTitle}</th>
</tr></thead> :
null}
<tbody>
{items}
</tbody></table>
<br />
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" />
</button>
<button className="white" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" />
</button>
</div>
</div>
);
}
};
export default injectIntl(PermissionsEditor);
-55
View File
@@ -1,55 +0,0 @@
// Received/read indicator.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { shortDateFormat } from '../lib/strformat.js';
const messages = defineMessages({
'sending': {
'id': 'message_sending',
'defaultMessage': 'sending...',
'description': 'Message being sent, in place of time stamp'
},
'failed': {
'id': 'message_sending_failed',
'defaultMessage': 'failed',
'description': 'Failed to send message, in place of time stamp'
}
});
class ReceivedMarker extends React.PureComponent {
render() {
const {formatMessage} = this.props.intl;
let timestamp;
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
timestamp = formatMessage(messages.sending);
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
timestamp = formatMessage(messages.failed);
} else {
timestamp = shortDateFormat(this.props.timestamp, this.props.intl.locale);
}
let marker = null;
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
marker = (<i className="material-icons small">access_time</i>); // watch face
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
marker = (<i className="material-icons small amber">warning</i>); // yellow icon /!\
} else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
marker = (<i className="material-icons small">done</i>); // checkmark
} else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
marker = (<i className="material-icons small">done_all</i>); // double checkmark
} else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
marker = (<i className="material-icons small blue">done_all</i>); // blue double checkmark
}
return (
<span className="timestamp">
{timestamp}{'\u00a0'}{marker}
</span>
);
}
};
export default injectIntl(ReceivedMarker);
-73
View File
@@ -1,73 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
export default class SearchContacts extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
edited: false,
search: ''
};
this.handleSearchChange = this.handleSearchChange.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleClear = this.handleClear.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
componentWillUnmount() {
if (this.state.edited) {
this.setState({search: '', edited: false});
this.props.onSearchContacts(Tinode.DEL_CHAR);
}
}
handleSearchChange(e) {
this.setState({search: e.target.value});
}
handleSearch(e) {
e.preventDefault();
var query = this.state.search.trim();
this.setState({edited: (query.length > 0)});
this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
}
handleClear() {
if (this.state.edited) {
this.props.onSearchContacts(Tinode.DEL_CHAR);
}
this.setState({search: '', edited: false});
}
handleKeyDown(e) {
if (e.key === 'Enter') {
this.handleSearch(e);
} else if (e.key === 'Escape') {
this.handleClear();
}
}
render() {
return (
<div className="panel-form">
<div className="panel-form-row">
<i className="material-icons search">search</i>
<FormattedMessage id="search_placeholder" defaultMessage="List like email:alice@example.com, tel:17025550003..."
description="Placeholder in contacts search field">{
(search_placeholder) => <input className="search" type="text"
placeholder={search_placeholder}
value={this.state.search} onChange={this.handleSearchChange}
onKeyDown={this.handleKeyDown} required autoFocus />
}</FormattedMessage>
<a href="javascript:;" onClick={this.handleClear}>
<i className="material-icons">close</i>
</a>
</div>
</div>
);
}
};
-223
View File
@@ -1,223 +0,0 @@
// Send message form.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import { KEYPRESS_DELAY, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64, imageFileScaledToBase64 } from '../lib/blob-helpers.js';
import { bytesToHumanSize } from '../lib/strformat.js';
const messages = defineMessages({
'messaging_disabled': {
id: 'messaging_disabled_prompt',
defaultMessage: 'Messaging disabled',
description: 'Prompt in SendMessage in read-only topic'
},
'type_new_message': {
id: 'new_message_prompt',
defaultMessage: 'New message',
description: 'Prompt in SendMessage in read-only topic'
},
'file_attachment_too_large': {
id: 'file_attachment_too_large',
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
description: 'Error message when attachment is too large'
},
'cannot_initiate_upload': {
id: 'cannot_initiate_file_upload',
defaultMessage: 'Cannot initiate file upload.',
description: 'Generic error messagewhen attachment fails'
},
});
class SendMessage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
message: '',
// Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
};
this.handlePasteEvent = this.handlePasteEvent.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleSend = this.handleSend.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleMessageTyping = this.handleMessageTyping.bind(this);
}
componentDidMount() {
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
}
componentWillUnmount() {
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
}
componentDidUpdate() {
this.messageEditArea.focus();
}
handlePasteEvent(e) {
if (this.props.disabled) {
return;
}
// FIXME: handle large files too.
if (filePasted(e,
(bits, mime, width, height, fname) => {
this.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
(mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
},
this.props.onError)) {
// If a file was pasted, don't paste base64 data into input field.
e.preventDefault();
}
}
handleAttachImage(e) {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
// Check if the uploaded file is indeed an image and if it isn't too large.
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
// Convert image for size or format.
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
// Success
(bits, mime, width, height, fname) => {
this.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
} else {
// Image can be uploaded as is. No conversion is needed.
imageFileToBase64(file,
// Success
(bits, mime, width, height, fname) => {
this.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
// Failure
(err) => {
this.props.onError(err, 'err');
}
);
}
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleAttachFile(e) {
const {formatMessage} = this.props.intl;
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
// Too large.
this.props.onError(formatMessage(messages.file_attachment_too_large,
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
} else if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(formatMessage(messages.cannot_initiate_upload));
return;
}
// Format data and initiate upload.
const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
// Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
fileToBase64(file,
(mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
},
this.props.onError
);
}
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleSend() {
const message = this.state.message.trim();
if (message) {
this.props.sendMessage(this.state.message.trim());
this.setState({message: ''});
}
}
/* Send on Enter key */
handleKeyPress(e) {
// Remove this if you don't want Enter to trigger send
if (e.key === 'Enter') {
// Have Shift-Enter insert a line break instead
if (!e.shiftKey) {
e.preventDefault();
e.stopPropagation();
this.handleSend();
}
}
}
handleMessageTyping(e) {
const newState = {message: e.target.value};
const now = new Date().getTime();
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (topic.isSubscribed()) {
topic.noteKeyPress();
}
newState.keypressTimestamp = now;
}
this.setState(newState);
}
render() {
const {formatMessage} = this.props.intl;
const prompt = this.props.disabled ?
formatMessage(messages.messaging_disabled) :
formatMessage(messages.type_new_message);
return (
<div id="send-message-panel">
{this.props.disabled ?
<i className="material-icons disabled">photo</i> :
<a href="javascript:;" onClick={(e) => {this.attachImage.click();}} title="Add image">
<i className="material-icons secondary">photo</i>
</a>}
{this.props.disabled ?
<i className="material-icons disabled">attach_file</i> :
<a href="javascript:;" onClick={(e) => {this.attachFile.click();}} title="Attach file">
<i className="material-icons secondary">attach_file</i>
</a>}
<textarea id="sendMessage" placeholder={prompt}
disabled={this.props.disabled} value={this.state.message}
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />
{this.props.disabled ?
<i className="material-icons disabled">send</i> :
<a href="javascript:;" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i>
</a>}
<input type="file" ref={(ref) => {this.attachFile = ref;}}
onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
onChange={this.handleAttachImage} style={{display: 'none'}} />
</div>
);
}
};
export default injectIntl(SendMessage);
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
import MenuCancel from './menu-cancel.jsx';
import MenuContacts from './menu-contacts.jsx';
import MenuStart from './menu-start.jsx';
export default class SideNavbar extends React.PureComponent {
render() {
return (
<div id="side-caption-panel" className="caption-panel">
<div id="self-avatar" className="avatar-box">
<LetterTile
avatar={this.props.avatar}
topic={this.props.myUserId}
title={this.props.title} />
</div>
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
{this.props.state === 'login' ?
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
this.props.state === 'contacts' ?
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
null}
{this.props.onCancel ?
<MenuCancel onCancel={this.props.onCancel} /> : null}
</div>
);
}
};
-151
View File
@@ -1,151 +0,0 @@
// TagManager: edit topic or user tags.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ChipInput from './chip-input.jsx';
import { MIN_TAG_LENGTH } from '../config.js';
import { arrayEqual } from '../lib/utils.js';
export default class TagManager extends React.Component {
constructor(props) {
super(props);
this.state = {
tags: this.props.tags,
tagInput: '',
activated: this.props.activated
};
this.handleShowTagManager = this.handleShowTagManager.bind(this);
this.handleTagInput = this.handleTagInput.bind(this);
this.handleAddTag = this.handleAddTag.bind(this);
this.handleRemoveTag = this.handleRemoveTag.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
return {tags: nextProps.tags};
}
return null;
}
handleShowTagManager() {
this.setState({activated: true});
}
handleTagInput(text) {
this.setState({tagInput: text});
if (text.length > 0) {
const last = text[text.length-1];
if (text[0] == '"') {
// This is a quoted string.
if (text.length > 1 && last == '"') {
this.handleAddTag(text.substring(1, text.length-1));
}
} else if (last == ',' || last == ' ' || last == ';' || last == '"') {
// User entered ',', ' ' or ';'
this.handleAddTag(text.substring(0, text.length-1).trim());
}
}
}
handleAddTag(tag) {
if (tag.length > 0) {
const tags = this.state.tags.slice(0);
tags.push(tag);
this.setState({tags: tags, tagInput: ''});
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
return tags;
}
return this.state.tags;
}
handleRemoveTag(tag, index) {
const tags = this.state.tags.slice(0);
tags.splice(index, 1);
this.setState({tags: tags});
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
}
handleSubmit() {
// Add unprocessed input to tags, submit the list.
this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim()));
this.setState({activated: false, tags: this.props.tags});
}
handleCancel() {
this.setState({activated: false, tagInput: '', tags: this.props.tags});
if (this.props.onCancel) {
this.props.onCancel();
}
}
render() {
let tags = [];
if (this.state.activated) {
this.state.tags.map((tag) => {
tags.push({user: tag, invalid: (tag.length < MIN_TAG_LENGTH)});
});
} else {
this.state.tags.map((tag) => {
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
});
if (tags.length == 0) {
tags = (
<i>
<FormattedMessage id="tags_not_found" defaultMessage="No tags defined. Add some." description="" />
</i>
);
}
}
return (
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small">{this.props.title}</label>
</div>
{this.state.activated ?
<div>
<FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags"
description="Tag editor prompt when no tags are found.">{
(add_tags_prompt) => <ChipInput
chips={tags}
avatarDisabled={true}
prompt={add_tags_prompt}
onEnter={this.handleAddTag}
onFocusLost={this.handleAddTag}
onCancel={this.handleCancel}
onChipRemoved={this.handleRemoveTag}
filterFunc={this.handleTagInput} />
}</FormattedMessage>
{this.props.onSubmit || this.props.onCancel ?
<div id="tag-manager-buttons" className="panel-form-row">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defautMessage="OK" description="Confirmation button [OK]" />
</button>
<button className="white" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defautMessage="Cancel" description="Rejection button [Cancel]" />
</button>
</div>
: null}
</div>
:
<div>
<a href="javascript:;" className="flat-button" onClick={this.handleShowTagManager}>
<i className="material-icons">edit</i> <FormattedMessage id="title_manage_tags" defaultMessage="Manage tags"
description="Section title for the list of tags" />
</a>
<span>{tags}</span>
</div>
}
</div>
);
}
};
-10
View File
@@ -1,10 +0,0 @@
// The counter of unread messages in the topic.
import React from 'react';
export default class UnreadBadge extends React.PureComponent {
render() {
return (this.props.count > 0 ?
<span className="unread">{this.props.count > 9 ? "9+" : this.props.count}</span>
: null);
}
};
-80
View File
@@ -1,80 +0,0 @@
// Password with a visiblity toggle.
import React from 'react';
export default class VisiblePassword extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
value: this.props.value,
visible: false
};
this.handleVisibility = this.handleVisibility.bind(this);
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handeTextChange(e) {
this.setState({value: e.target.value});
if (this.props.onChange) {
this.props.onChange(e);
}
}
handleVisibility(e) {
e.preventDefault();
this.setState({visible: !this.state.visible});
}
handleKeyDown(e) {
if (e.keyCode == 27) {
// Escape pressed
this.setState({value: this.props.value, visible: false});
if (this.props.onFinished) {
this.props.onFinished();
}
} else if (e.keyCode == 13) {
// Enter pressed
this.handleEditingFinished();
}
}
handleEditingFinished(e) {
if (e) {
let currentTarget = e.currentTarget;
setTimeout(() => {
if (!currentTarget.contains(document.activeElement)) {
if (this.props.onFinished) {
this.props.onFinished(this.state.value);
}
}
}, 0);
} else if (this.props.onFinished) {
this.props.onFinished(this.state.value.trim());
}
}
render() {
return (
<div tabIndex="-1" className="group-focus"
onBlur={this.handleEditingFinished}>
<input className="with-visibility"
type={this.state.visible ? "text" : "password"}
value={this.state.value}
placeholder={this.props.placeholder}
required={this.props.required ? 'required' : ''}
autoFocus={this.props.autoFocus ? 'autoFocus' : ''}
autoComplete={this.props.autoComplete}
onChange={this.handeTextChange}
onKeyDown={this.handleKeyDown} />
<span onClick={this.handleVisibility}>
<i className="material-icons clickable light-gray">
{this.state.visible ? 'visibility' : 'visibility_off'}
</i>
</span>
</div>
);
}
}
+64
View File
@@ -0,0 +1,64 @@
from pathlib import Path
import unittest
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
DOCKERFILE = Path(__file__).resolve().parents[1] / "Dockerfile"
API_CLIENT = Path(__file__).resolve().parents[1] / "web/src/lib/api/client.ts"
class DeployWorkflowContractTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
cls.dockerfile = DOCKERFILE.read_text(encoding="utf-8")
cls.api_client = API_CLIENT.read_text(encoding="utf-8")
def test_targets_pro_branch_and_single_runner(self):
self.assertIn("push:", self.workflow)
self.assertIn("- pro", self.workflow)
self.assertIn("workflow_dispatch:", self.workflow)
self.assertIn("runs-on: deploy-host", self.workflow)
self.assertIn("concurrency:", self.workflow)
def test_builds_and_pushes_pro_image(self):
self.assertIn("git.olns.shop/pro/im_webapp", self.workflow)
self.assertIn("docker push", self.workflow)
self.assertIn(":pro", self.workflow)
def test_deploys_compose_service_with_pinned_host_key(self):
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
self.assertIn("docker compose pull im-webapp", self.workflow)
self.assertIn("docker compose up -d --no-deps --wait im-webapp", self.workflow)
def test_removes_old_kubernetes_and_registry_targets(self):
self.assertNotIn("kubectl", self.workflow)
self.assertNotIn("KUBECONFIG", self.workflow)
self.assertNotIn("git.118im.com", self.workflow)
def test_uses_same_origin_im_endpoints(self):
self.assertNotIn("TINODE_API_BASE=", self.workflow)
self.assertNotIn("TINODE_FILE_BASE=", self.workflow)
self.assertNotIn("im-api.yuexuan.shop", self.workflow)
self.assertIn("ARG TINODE_FILE_BASE=", self.dockerfile)
self.assertNotIn("api.118im.com", self.dockerfile)
def test_build_exposes_configured_api_base_to_vite(self):
self.assertIn("ARG TINODE_API_BASE=", self.dockerfile)
self.assertIn("ENV VITE_TINODE_API_BASE=$TINODE_API_BASE", self.dockerfile)
def test_strips_accidentally_quoted_api_key_before_build(self):
self.assertIn('TINODE_API_KEY="${TINODE_API_KEY#\\\'}"', self.workflow)
self.assertIn('TINODE_API_KEY="${TINODE_API_KEY%\\\'}"', self.workflow)
def test_api_client_keeps_same_origin_defaults(self):
self.assertIn("import.meta.env.VITE_TINODE_API_BASE?.trim()", self.api_client)
self.assertIn("CONFIGURED_API_BASE || endpoints?.api || DEFAULT_API_BASE", self.api_client)
self.assertIn("const DEFAULT_API_BASE = '/v1/im'", self.api_client)
self.assertIn("const DEFAULT_FILE_BASE = '/v0/file'", self.api_client)
if __name__ == "__main__":
unittest.main()
-10894
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

Some files were not shown because too many files have changed in this diff Show More