Compare commits

..
1 Commits
Author SHA1 Message Date
dev c7948236eb Initial commit 2026-10-18 12:19:40 +08:00
148 changed files with 137 additions and 23356 deletions
-40
View File
@@ -1,40 +0,0 @@
name: Deploy im-webapp (k3s test cluster)
on:
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
jobs:
deploy:
runs-on: deploy-host
steps:
- uses: actions/checkout@v4
- name: 确定镜像tag
id: vars
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 构建并推送镜像
run: |
docker build \
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" \
--build-arg TINODE_FILE_BASE="https://api.118im.com/v0/file" \
-t git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }}
- name: 部署到k3s测试集群
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-webapp -n im-webapp-system
kubectl rollout status deployment/im-webapp -n im-webapp-system --timeout=120s
+136 -7
View File
@@ -1,9 +1,138 @@
.DS_Store
.git.worktree-pointer.bak
node_modules
.idea/
*.code-workspace
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Local developer overrides — never commit real keys.
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.claude/
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
-38
View File
@@ -1,38 +0,0 @@
# ---- 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
# File downloads must use the canonical IM API origin. The web origin has its own independently
# deployed path router and may otherwise send /v0/file requests to an older IM server.
ARG TINODE_FILE_BASE=https://api.118im.com/v0/file
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
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -43
View File
@@ -1,44 +1,2 @@
# 迅工捷使 Web
# im_webapp
当前前端位于 `web/`,技术栈为 React、TypeScript 和 Vite。仓库根目录不再保留旧版 Tinode Webpack 应用及其构建产物。
## 本地开发
```bash
cd web
npm ci
npm run dev
```
## 检查与构建
```bash
cd web
node --test tests/*.test.mjs tests/*.test.ts
npm run lint
npm run build
```
生产构建输出到 `web/dist/`。
## Docker 部署
根目录 `Dockerfile` 只复制并构建 `web/`:
```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 代理。
测试集群通过 Gitea Actions 的 `Deploy im-webapp (k3s test cluster)` 工作流手动发布。
## 目录
- `web/`:当前 Vite 前端源码、测试和静态资源
- `docker/`:Nginx 配置
- `docs/`:设计及实施文档
- `.gitea/workflows/`:测试集群部署工作流
-24
View File
@@ -1,24 +0,0 @@
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";
}
}
@@ -1,92 +0,0 @@
# 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.
@@ -1,151 +0,0 @@
# 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` 验证标题、品牌、登录页删减及双账号新消息提醒路径。
@@ -1,23 +0,0 @@
# 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/微信运行环境,最终明确说明这一端到端组合尚未实测,不宣称已覆盖所有微信版本。
本次只做本地代码与验证;不推送、不部署。
@@ -1,34 +0,0 @@
# 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`。
- 部署后实测企业码页、登录页、浏览器标题、图标,以及双账号新消息提示音路径。
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/app-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
<title>迅工捷使</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-2944
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"test": "node --test tests/*.test.mjs tests/*.test.ts",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"agora-rtc-sdk-ng": "^4.24.8",
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.9.6",
"tinode-sdk": "^0.25.3",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^24.13.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"tailwindcss": "^4.1.16",
"typescript": "^5.6.0",
"vite": "^8.2.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.
-141
View File
@@ -1,141 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Navigate, createBrowserRouter, RouterProvider, useRouteError } from 'react-router-dom';
import { AppShell } from '@/app/AppShell';
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
import { Icon } from '@/app/Icon';
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
import { RequireAuth } from '@/app/RequireAuth';
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage';
import { RegisterPage } from '@/features/auth/RegisterPage';
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage';
import { CallHistoryView } from '@/features/calls/CallHistoryView';
import { BlocklistView } from '@/features/contacts/BlocklistView';
import { ContactsLayout } from '@/features/contacts/ContactsLayout';
import { EmptyContactsMain } from '@/features/contacts/EmptyContactsMain';
import { FriendProfileView } from '@/features/contacts/FriendProfileView';
import { GroupListView } from '@/features/contacts/GroupListView';
import { NewFriendsView } from '@/features/contacts/NewFriendsView';
import { FavoritesLayout } from '@/features/favorites/FavoritesLayout';
import { ConversationView } from '@/features/messages/ConversationView';
import { EmptyMain } from '@/features/messages/EmptyMain';
import { MessagesLayout } from '@/features/messages/MessagesLayout';
import { GeneralSettingsPage } from '@/features/settings/GeneralSettingsPage';
import { NotificationsSettingsPage } from '@/features/settings/NotificationsSettingsPage';
import { PrivacySettingsPage } from '@/features/settings/PrivacySettingsPage';
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
import { SecuritySettingsPage } from '@/features/settings/SecuritySettingsPage';
import { SettingsHome } from '@/features/settings/SettingsHome';
import { SettingsLayout } from '@/features/settings/SettingsLayout';
import { SystemMessagesView } from '@/features/system/SystemMessagesView';
const queryClient = new QueryClient();
/**
* Without this, an uncaught render-time exception ANYWHERE under `<AppShell>` (any page, any one
* bad field in server data) falls through to react-router's own built-in default error boundary --
* real, not hypothetical (confirmed live: `node_modules/react-router` DOES implicitly wrap the
* outermost matched route the way this codebase never configured one to). That default shows
* "Unexpected Application Error!" plus the raw error message/stack to the end user and replaces the
* WHOLE logged-in shell, sidebar included, with no way back except a full page reload. Attached to
* AppShell (not each leaf route) so it covers every authenticated page with one component, and low
* enough that `RequireAuth`/login/tenant-entry are never affected by a bug in, say, 收藏.
*/
function RouteErrorBoundary() {
const error = useRouteError();
if (import.meta.env.DEV) console.error(error);
return (
<div className="flex h-dvh w-full flex-col items-center justify-center gap-3 bg-surface-page px-6 text-center">
<Icon name="error_outline" className="text-[40px] text-text-tertiary-3" />
<p className="text-sm text-text-secondary">页面出现异常,请刷新重试</p>
<button type="button" onClick={() => window.location.reload()} className="rounded-lg bg-accent px-4 py-2 text-sm text-white">
刷新页面
</button>
</div>
);
}
const router = createBrowserRouter([
{ path: 'tenant', element: <TenantEntryPage /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'reset-password', element: <ResetPasswordPage /> },
{ path: 'enterprise-register', element: <EnterpriseRegisterPage /> },
{ path: 'feedback', element: <TenantFeedbackPage /> },
{
element: <RequireAuth />,
children: [
{
element: <AppShell />,
errorElement: <RouteErrorBoundary />,
children: [
{ index: true, element: <Navigate to="/messages" replace /> },
{
path: 'messages',
element: <MessagesLayout />,
children: [
{ index: true, element: <EmptyMain /> },
{ path: 'system', element: <SystemMessagesView /> },
{ path: ':topicId', element: <ConversationView /> },
],
},
{
path: 'contacts',
element: <ContactsLayout />,
children: [
{ index: true, element: <EmptyContactsMain /> },
{ path: 'requests', element: <NewFriendsView /> },
{ path: 'groups', element: <GroupListView /> },
{ path: 'blocklist', element: <BlocklistView /> },
{ path: ':userId', element: <FriendProfileView /> },
],
},
{ path: 'favorites', element: <FavoritesLayout /> },
// README §9.2's dedicated file-assistant browser (categories, storage quota, upload
// progress) has NO backing REST API at all (confirmed via a research pass -- the design
// spec's own state-management notes admit reuse of existing APIs, i.e. reading the `slf`
// topic's messages, same as any other conversation) -- redirecting into the real
// conversation on that topic is honest; a fake quota card with invented numbers isn't.
{ path: 'files', element: <Navigate to="/messages/slf" replace /> },
{ path: 'calls', element: <CallHistoryView /> },
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <SettingsHome /> },
{ path: 'profile', element: <ProfileSettingsPage /> },
{ path: 'security', element: <SecuritySettingsPage /> },
{ path: 'notifications', element: <NotificationsSettingsPage /> },
{ path: 'privacy', element: <PrivacySettingsPage /> },
{ path: 'general', element: <GeneralSettingsPage /> },
],
},
],
},
],
},
// A stale bookmark/link to a route renamed during this redesign should land somewhere the app
// itself resolves (tenant/login/messages, per RequireAuth's own gating), not React Router's
// generic unstyled default error boundary.
{ path: '*', element: <Navigate to="/" replace /> },
]);
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{/* Mounted above the router (not inside AppShell) -- window.confirm()/window.alert()
replacements need to work on unauthenticated pages too (e.g. TenantEntryPage's "switch
tenant" confirmation), not just the logged-in app tree. */}
<ConfirmDialogRoot />
<GlobalToastRoot />
<KickedNoticeRoot />
<SecurityChallengeModal />
</QueryClientProvider>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Outlet, useNavigate } from 'react-router-dom';
import { UserCardRoot } from '@/app/UserCardRoot';
import { CallRoot } from '@/features/calls/CallRoot';
import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile';
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
import { useChatList } from '@/lib/tinode/hooks';
import { subscribeTopicInfo } from '@/lib/tinode/topics';
import { initCallSignaling } from '@/stores/calls';
import { useNotificationNavStore } from '@/stores/notificationNav';
import { BottomTabBar } from './BottomTabBar';
import { NavRail } from './NavRail';
/**
* Top-level chrome: persistent icon rail (>=640px) or bottom tab bar (<640px), wrapping whichever
* feature section is active. Each feature section owns its own list/main/detail responsive split
* internally (see e.g. features/messages) -- this shell only decides "rail vs tabs", nothing about
* columns within a section.
*/
export function AppShell() {
const navigate = useNavigate();
const contacts = useChatList();
const bootstrap = useBootstrap();
// Same `['me']` query SettingsLayout already runs -- sharing the key means whichever mounts
// first fetches it and the other just reads the cache, not two redundant requests. This was
// never wired up here before, so the rail's self-tile (NavRail below) had no avatar/name data
// at all and fell back to a permanently-hardcoded "我" glyph regardless of who was logged in or
// what avatar they'd uploaded -- see Avatar.tsx's own comment for the wider version of this gap.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
// Once per app session, regardless of which page is open -- an incoming call must surface even
// while browsing contacts/settings/etc, not only while a conversation happens to be open.
useEffect(() => {
initCallSignaling();
}, []);
const queryClient = useQueryClient();
// Friend-request realtime signals (Zentao #3): after a request is created/accepted/rejected the
// server pushes `{info what:"im" event:"friend.request.*"}` to the recipient's `me` topic
// (publishIMRelationEvent -> sessionStore.DeliverToUser). Nothing here used to listen for it --
// the SDK's _routeInfo hits its default case for what:"im" and only fires the onInfo chain -- so
// the 新朋友 badge/list stayed stale until a manual refresh or relogin. Subscribe once per app
// session (same shape as initCallSignaling above) and invalidate the shared ['friend-requests']
// query namespace: ContactsListColumn's pending badge (['friend-requests','inbound','pending'])
// and NewFriendsView's list (['friend-requests','all']) both refetch on invalidation. Listening
// here rather than in those views keeps the badge live no matter which page the user is on.
useEffect(() => {
return subscribeTopicInfo((topicName, info) => {
if (info?.event?.startsWith('friend.request.')) {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
}
if (info?.event === 'group.member.profile.updated' && topicName.startsWith('grp')) {
queryClient.invalidateQueries({ queryKey: ['group-members', topicName] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicName] });
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicName] });
}
});
}, [queryClient]);
// "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
// in case it ever also shows up as a regular Tinode subscription in `contacts`, which would
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
// 文件助手 (which used to own its own entry_badges count, file_assistant_unread_count) has been
// removed as a UI entry point entirely -- see ChatListColumn.tsx's PINNED_ENTRIES doc comment --
// so "slf" is now just an ordinary contact row like any other, and should count normally here too.
const unreadMessages =
contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
(bootstrap.data?.entry_badges?.system_unread_count || 0);
// Same canonical number the rail/tab-bar badges above already show -- keeps the browser tab's
// favicon badge and (while backgrounded) scrolling title in sync with it rather than tracking a
// second, potentially-divergent count.
useEffect(() => {
updateTabIndicator(unreadMessages);
}, [unreadMessages]);
// Consumes a desktop-notification click (lib/notifications/push.ts has no React Router access of
// its own -- see stores/notificationNav.ts for why this goes through a store instead).
const pendingNotificationTopic = useNotificationNavStore((s) => s.pendingTopic);
useEffect(() => {
if (!pendingNotificationTopic) return;
navigate(`/messages/${pendingNotificationTopic}`);
useNotificationNavStore.getState().clear();
}, [pendingNotificationTopic, navigate]);
return (
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
<div className="hidden sm:flex sm:h-full">
<NavRail
unreadMessages={unreadMessages}
selfId={meQuery.data?.user_id}
selfName={meQuery.data?.profile.nickname || '我'}
selfAvatarFileId={meQuery.data?.profile.avatar_file_id}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<Outlet />
</div>
<div className="sm:hidden">
<BottomTabBar unreadMessages={unreadMessages} />
</div>
</div>
<CallRoot />
<UserCardRoot />
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl } from '@/lib/hooks';
interface AvatarProps {
/** Stable id (user_id/topic) used ONLY for the deterministic fallback color -- never the display name. */
id: string;
name: string | undefined;
avatarFileId?: string;
/** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's
* own color/centering/weight are applied internally, not expected from the caller. */
className?: string;
/** Overrides the hashed `avatarBgClass(id)` fallback color -- e.g. "bg-accent" for the current
* user's own message-row avatar, which by design is always the fixed accent color rather than a
* per-id hash like every other tile. No effect once avatarFileId resolves to a real image. */
fallbackBgClass?: string;
}
/**
* A real uploaded avatar (`avatar_file_id`) was, until now, stored and successfully round-tripped
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
* avatar produced zero visible change anywhere, including on the very page the upload happened.
* `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape
* `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat
* attachments) reconstructed -- but live-confirmed (2026-09-18): default-avatar placeholders come
* back as an ALREADY-COMPLETE OSS URL in this same field (e.g.
* "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/im-assets/default-avatars/male/4.png"). A first
* fix attempt stopped re-wrapping that URL into the malformed "/v0/file/s/https://..." shape but
* still routed it through `useAuthedBlobUrl`'s authenticated fetch, which then failed when tested
* from a local dev origin -- turned out to be a false lead: the OSS bucket's CORS rule (checked
* directly in the Aliyun console) DOES allow this app's auth headers, just scoped to the real
* `web/web1-5.118im.com` origins, which a local dev server was never going to match regardless of
* bucket config (same class of mistake this codebase has hit before testing OSS URLs from
* localhost). The actual reason to skip `useAuthedBlobUrl` for an absolute URL isn't "CORS is
* broken" -- it's that the auth headers it attaches are meaningless to OSS for a public asset in the
* first place, so the fetch-to-blob round trip (plus its preflight) is pure overhead; render the URL
* directly as a plain `<img src>` instead, same as any other publicly-loadable image.
*/
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) {
const isAbsoluteUrl = !!avatarFileId && /^https?:\/\//.test(avatarFileId);
const remote = useAuthedBlobUrl(avatarFileId && !isAbsoluteUrl ? `/v0/file/s/${avatarFileId}` : undefined);
const resolvedUrl = isAbsoluteUrl ? avatarFileId : remote.url;
if (resolvedUrl) {
return <img src={resolvedUrl} alt="" className={`object-cover ${className}`} />;
}
return (
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
{avatarLetter(name)}
</span>
);
}
-48
View File
@@ -1,48 +0,0 @@
import { NavLink } from 'react-router-dom';
import { Icon } from './Icon';
interface TabItem {
to: string;
icon: string;
label: string;
badge?: number;
}
/**
* Replaces the old app's message-center-view.jsx-embedded 2-tab (消息/工作台) bar: this one is
* top-level (visible regardless of which section is open, not just inside the message list) and
* has 3 tabs per the new design, since 工作台 no longer exists.
*/
export function BottomTabBar({ unreadMessages = 0 }: { unreadMessages?: number }) {
const tabs: TabItem[] = [
{ to: '/messages', icon: 'chat_bubble', label: '消息', badge: unreadMessages },
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
{ to: '/settings', icon: 'person', label: '我的' },
];
return (
<nav className="grid h-[82px] flex-none grid-cols-3 border-t border-border-panel bg-white pb-4.5">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
`relative flex flex-col items-center justify-center gap-0.5 text-[11px] no-underline ${
isActive ? 'text-accent' : 'text-text-tertiary-3'
}`
}
>
<span className="relative">
<Icon name={tab.icon} className="!text-2xl" />
{tab.badge ? (
<b className="absolute -right-2 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
{tab.badge}
</b>
) : null}
</span>
{tab.label}
</NavLink>
))}
</nav>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { useEscapeKey } from '@/lib/hooks';
import { useConfirmDialogStore } from '@/stores/confirmDialog';
/** Mounted once at the app root (App.tsx) -- renders whatever `requestConfirm()` (confirmDialog.ts)
* most recently asked for. Visual language matches this app's other small action dialogs (e.g. the
* forward picker's outlined-gray "取消" + solid colored confirm button, `02-messages.dc.html`'s own
* spec for that pairing) rather than inventing a new button treatment just for this. */
export function ConfirmDialogRoot() {
const pending = useConfirmDialogStore((s) => s.pending);
const respond = useConfirmDialogStore((s) => s.respond);
// Always called (hooks can't be conditional) -- a no-op onEscape while nothing is pending is
// harmless, matching how every other modal in this app registers unconditionally too.
useEscapeKey(() => respond(false));
if (!pending) return null;
const { title, message, confirmLabel = '确定', cancelLabel = '取消', danger } = pending;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(false)}>
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
{title ? <strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong> : null}
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={() => respond(false)}
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => respond(true)}
className={`grid h-9 place-items-center rounded-lg px-4 text-sm font-medium text-white ${danger ? 'bg-danger-text' : 'bg-btn'}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { Toast } from '@/app/Toast';
import { useGlobalToastStore } from '@/stores/globalToast';
/** Mounted once at the app root (App.tsx) -- renders whatever `notify()` (globalToast.ts) most
* recently set. `key={token}` forces Toast to remount (not just re-render) so its own auto-dismiss
* timer restarts even when the exact same message is fired twice in a row, which a plain `message`
* string prop wouldn't detect as a change. */
export function GlobalToastRoot() {
const message = useGlobalToastStore((s) => s.message);
const variant = useGlobalToastStore((s) => s.variant);
const token = useGlobalToastStore((s) => s.token);
if (!message) return null;
return <Toast key={token} message={message} variant={variant} onDone={() => useGlobalToastStore.setState({ message: '' })} />;
}
-16
View File
@@ -1,16 +0,0 @@
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html).
* Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g.
* "arrow_back_ios_new"), never something a screen reader should announce. An icon-only
* interactive element (button/link with no visible text) must set its own `aria-label`.
*
* Deliberately `im-icon`, NOT Google Fonts' own `.material-icons` class -- see index.css's own
* comment on that rule for why: Google's stylesheet ships that class completely unlayered, which
* under CSS Cascade Layers always outranks Tailwind's layered utilities regardless of specificity
* or source order, silently ignoring every `text-[Npx]` size override across the whole app. */
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
return (
<i aria-hidden="true" className={`im-icon select-none ${className}`} style={style}>
{name}
</i>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { clearKickedInfo } from '@/lib/tinode/connection';
import { kickedNoticeText } from '@/lib/tinode/errors';
import { useKickedInfo } from '@/lib/tinode/hooks';
/**
* Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot --
* an eviction can land on any authenticated page, not just ones that happen to render their own
* connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already
* torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's
* only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端
* 接口文档.md §2.4's "弹出中文提示后跳转登录页".
*
* A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for
* an eviction that already happened, offering one would be misleading. Hard `window.location.href`
* navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical
* reason: every piece of in-memory state (TanStack Query cache, any component's local state) should
* reset from scratch, not rely on each one separately noticing "you're logged out now".
*/
export function KickedNoticeRoot() {
const kicked = useKickedInfo();
if (!kicked) return null;
const { title, message } = kickedNoticeText(kicked.reason);
const acknowledge = () => {
clearKickedInfo();
window.location.href = '/login';
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25">
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal">
<strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong>
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
<div className="mt-5 flex justify-end">
<button type="button" onClick={acknowledge} className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white">
确定
</button>
</div>
</div>
</div>
);
}
-86
View File
@@ -1,86 +0,0 @@
import { Link, useLocation } from 'react-router-dom';
import { Avatar } from './Avatar';
import { Icon } from './Icon';
interface NavRailItem {
to: string;
icon: string;
label: string;
badge?: number;
}
const TOP_ITEMS: NavRailItem[] = [
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
{ to: '/favorites', icon: 'inventory_2', label: '收藏' },
];
const BOTTOM_ITEMS: NavRailItem[] = [
{ to: '/calls', icon: 'phone', label: '通话记录' },
{ to: '/settings', icon: 'settings', label: '设置' },
];
function isNavItemActive(to: string, pathname: string): boolean {
if (to === '/messages') return pathname.startsWith('/messages');
return pathname === to || pathname.startsWith(`${to}/`);
}
function NavRailButton({ item }: { item: NavRailItem }) {
const location = useLocation();
const isActive = isNavItemActive(item.to, location.pathname);
return (
// Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match
// logic unconditionally (confirmed by reading react-router's source: passing an `aria-current`
// prop only changes the STRING used, not whether it's applied at all, which stays gated on
// NavLink's internal isActive), so a second, independent aria-current computation could in
// principle disagree with the `className` highlight computed by `isActive` below. Driving both
// off the one shared `isActive` value keeps them impossible to desync.
<Link
to={item.to}
title={item.label}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
}`}
>
<Icon name={item.icon} className="!text-2xl" />
{item.badge ? (
<b className="absolute top-1 right-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
{item.badge}
</b>
) : null}
</Link>
);
}
interface NavRailProps {
unreadMessages?: number;
/** The logged-in user's own id/name/avatar (`GET /v1/im/me`) -- drives the rail's top self-tile.
* All optional so the rail still renders (as a letter-tile fallback) before that request settles. */
selfId?: string;
selfName?: string;
selfAvatarFileId?: string;
}
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
export function NavRail({ unreadMessages = 0, selfId, selfName, selfAvatarFileId }: NavRailProps) {
return (
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
<Link to="/settings/profile" title="个人资料" aria-label="个人资料" className="mb-[22px]">
<Avatar id={selfId || ''} name={selfName} avatarFileId={selfAvatarFileId} className="h-[38px] w-[38px] rounded-[10px] text-base" />
</Link>
<div className="flex flex-1 flex-col items-center gap-1.5">
{TOP_ITEMS.map((item) => (
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
))}
</div>
<div className="flex flex-col items-center gap-1.5">
{BOTTOM_ITEMS.map((item) => (
<NavRailButton key={item.to} item={item} />
))}
</div>
</nav>
);
}
-141
View File
@@ -1,141 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast';
import { initMessageNotifications } from '@/lib/notifications/push';
import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors';
import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
* socket's own onopen/onerror) -- past this, assume the host is unreachable in a way that won't
* self-resolve (stalled handshake, silently-dropped middlebox) rather than hang the restore screen
* forever with no escape route. */
const RESTORE_TIMEOUT_MS = 10_000;
/**
* Gate for everything inside AppShell: needs a resolved tenant and an authenticated user.
*
* `tenant` survives a same-tab reload (persisted, see stores/session.ts) but `myUserId` doesn't --
* it used to always come back `null` on reload, bouncing straight to /login even though every
* login/register already writes a perfectly good reusable token to `sessionStorage['im-session-
* token']` for exactly this case. This re-derives `myUserId` from that token via connection.ts's
* restoreSession() (deduped against React 19 StrictMode's double-invoke in dev -- see its own
* comment) before giving up and redirecting, instead of ignoring the token entirely.
*/
export function RequireAuth() {
const tenant = useSessionStore((s) => s.tenant);
const myUserId = useSessionStore((s) => s.myUserId);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const clearTenant = useSessionStore((s) => s.clearTenant);
const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
const [restoreFailed, setRestoreFailed] = useState(false);
const [restoreTimedOut, setRestoreTimedOut] = useState(false);
const [contactsError, setContactsError] = useState('');
const meSubscribedRef = useRef(false);
useEffect(() => {
if (myUserId || !tenant || !hasToken) return undefined;
const token = sessionStorage.getItem('im-session-token');
if (!token) return undefined;
let cancelled = false;
const timeout = window.setTimeout(() => {
if (!cancelled) setRestoreTimedOut(true);
}, RESTORE_TIMEOUT_MS);
restoreSession(token)
.then((uid) => {
if (cancelled) return;
setMyUserId(uid);
})
.catch((err) => {
if (!cancelled) {
sessionStorage.removeItem('im-session-token');
// docs/IM-前后端对接文档.md: a stale token from a DIFFERENT tenant than the one currently
// resolved fails with this specific reason, whose prescribed recovery is "清 Token 并回
// 企业码页" -- clearing the tenant here lets the fallback `<Navigate to="/login"/>` below
// do that for free, since LoginPage's own `if (!tenant)` guard bounces straight to
// `/tenant`, rather than adding a second, parallel navigation path.
if (isTenantMismatch(err)) clearTenant();
setRestoreFailed(true);
}
})
.finally(() => {
window.clearTimeout(timeout);
});
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [tenant, myUserId, hasToken, setMyUserId, clearTenant]);
// Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless
// of HOW it became known (session restore above, or a fresh login that already called
// setMyUserId before this component ever mounted) -- LoginPage used to fire its own
// subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly logged-in user
// with an empty conversation list and no error on a transient failure. Centralizing here means it
// happens exactly once per authenticated mount and surfaces the same Toast either way.
useEffect(() => {
if (!myUserId || meSubscribedRef.current) return;
meSubscribedRef.current = true;
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
}, [myUserId]);
// Separate from the one-time subscribeMeTopic effect above on purpose: this one has real
// start/stop semantics (a timer + listeners, not a non-idempotent WS packet) and needs to
// restart cleanly across StrictMode's mount->cleanup->remount, not be guarded against it. See
// startMeReconciliation's own comment for what backend gap this is mitigating.
useEffect(() => {
if (!myUserId) return undefined;
return startMeReconciliation();
}, [myUserId]);
// Same start/stop shape as the reconciliation effect above, same reason it isn't merged into the
// one-time subscribeMeTopic effect.
useEffect(() => {
if (!myUserId) return undefined;
return initMessageNotifications();
}, [myUserId]);
if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) {
return (
<>
<Outlet />
<Toast message={contactsError} variant="error" onDone={() => setContactsError('')} />
</>
);
}
// `hasToken` is a mount-time snapshot, not live state -- re-checking sessionStorage directly here
// (rather than trusting that stale flag alone) catches the token having been cleared elsewhere in
// the meantime (connection.ts's reconnect-triggered restoreSession failure, or client.ts's global
// 401 handler both clear it without this component's own effect ever running). Without this,
// `hasToken` would still read true and the branch below would show "正在恢复登录状态…" forever --
// there's no token left to restore and nothing would ever resolve that screen.
if (hasToken && !restoreFailed && sessionStorage.getItem('im-session-token')) {
// restoreTimedOut only switches the MESSAGE, it must never force a navigate here -- doing so
// would unmount this component, which sets the effect's `cancelled` flag and makes the still-
// in-flight restoreSession() promise discard its own result via `if (cancelled) return`. A
// merely-slow connection (10s+ round trip on cellular/VPN, not necessarily a dead host) would
// otherwise get bounced to a forced re-login even when the restore was about to succeed a moment
// later. Only an actual rejection (restoreFailed) or the user's own click below should leave.
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
{/* Only past the same timeout the message text itself waits for -- a normal restore usually
resolves in well under a second (real WS round trip), and this link showing unconditionally
from the very first frame made every ordinary reload/deep-link flash a "重新登录" escape
hatch that had nothing to escape from (a real, reported false-alarm: "短暂白屏并误显改为
重新登录"). The escape hatch is only actually useful once restoreTimedOut has already told
the user this is taking longer than normal. */}
{restoreTimedOut ? (
<a href="/login" className="text-accent no-underline">
改为重新登录
</a>
) : null}
</div>
);
}
return <Navigate to="/login" replace />;
}
-144
View File
@@ -1,144 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useEscapeKey } from '@/lib/hooks';
import { ImApiError } from '@/lib/api/client';
import { requestSecurityChallenge, verifySecurityChallenge } from '@/lib/api/security';
import { useSecurityChallengeStore } from '@/stores/securityChallenge';
/** Mounted once at the app root (App.tsx), same placement/pattern as ConfirmDialogRoot -- renders
* whatever `requestActionToken()` (stores/securityChallenge.ts) most recently asked for: the
* protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766), needed before
* a group can actually be dissolved (or its ownership transferred) -- both require `X-Action-Token`,
* and there was previously no UI anywhere in this app that could produce one. */
export function SecurityChallengeModal() {
const pending = useSecurityChallengeStore((s) => s.pending);
const respond = useSecurityChallengeStore((s) => s.respond);
const [challengeId, setChallengeId] = useState('');
const [phoneMask, setPhoneMask] = useState('');
const [code, setCode] = useState('');
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const timerRef = useRef<number | undefined>(undefined);
useEscapeKey(() => respond(null));
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
timerRef.current = window.setInterval(() => {
setSeconds((s) => {
if (s <= 1) {
window.clearInterval(timerRef.current);
return 0;
}
return s - 1;
});
}, 1000);
};
// Fires the first SMS the moment the modal opens -- the doc's own flow has no separate "get code"
// click for this endpoint (unlike VerificationCodeButton's register/reset-password scenes), since
// the phone number isn't something the user enters here at all (read server-side from the login
// identity), so there's nothing to wait on the user to fill in first.
useEffect(() => {
window.clearInterval(timerRef.current);
setChallengeId('');
setCode('');
setError('');
setSeconds(0);
if (!pending) return undefined;
let cancelled = false;
requestSecurityChallenge(pending.scene)
.then((data) => {
if (cancelled) return;
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
tick(data.resend_after_seconds || 60);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
});
return () => {
cancelled = true;
window.clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending?.scene]);
if (!pending) return null;
const resend = () => {
if (busy || seconds > 0) return;
setError('');
setBusy(true);
requestSecurityChallenge(pending.scene)
.then((data) => {
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
tick(data.resend_after_seconds || 60);
})
.catch((err) => setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'))
.finally(() => setBusy(false));
};
const submit = async () => {
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请输入 6 位验证码');
setBusy(true);
setError('');
try {
const verified = await verifySecurityChallenge(challengeId, code);
respond(verified.action_token);
} catch (err) {
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(null)}>
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
<strong className="mb-2 block text-base font-semibold text-text-heading">安全验证</strong>
<p className="m-0 text-sm leading-relaxed text-text-body">
即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
</p>
<div className="mt-4 flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="请输入 6 位验证码"
inputMode="numeric"
autoFocus
className="h-9 min-w-0 flex-1 rounded-lg border border-border-panel px-3 text-sm outline-none focus:border-accent"
/>
<button
type="button"
onClick={resend}
disabled={busy || seconds > 0 || !challengeId}
className="h-9 w-28 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
>
{seconds > 0 ? `重新获取(${seconds}s)` : '重新获取'}
</button>
</div>
{error ? <p className="m-0 mt-2 text-xs text-danger-text">{error}</p> : null}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={() => respond(null)}
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
>
取消
</button>
<button
type="button"
onClick={submit}
disabled={busy || !challengeId || code.length !== 6}
className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? '验证中…' : '确定'}
</button>
</div>
</div>
</div>
);
}
-32
View File
@@ -1,32 +0,0 @@
interface SelectionCheckboxProps {
selected: boolean;
className?: string;
}
/**
* Shared circular multi-select indicator -- every list in this app that lets you pick multiple
* items (forward targets, message multi-select, create-group contact picker, favorites, blocklist)
* used to hand-copy this same circle-that-fills-with-a-checkmark markup into its own file, each
* with small, accidental class differences (a stray extra border in the selected state here, a
* slightly different gray there) -- fixing the same visual bug five separate times is exactly the
* failure mode a shared component exists to prevent.
*
* Draws the checkmark as a hand-drawn SVG path, not the Material Icons font glyph `Icon` uses
* everywhere else: the font's hinting isn't tuned for a ~13px glyph inside a 20px circle, so above
* 1x pixel density it renders visibly soft/asymmetric where a vector path stays crisp at any scale.
* A deliberate one-off exception to this app's usual `Icon`-for-everything convention, not a second
* icon system -- scoped to this one selection indicator.
*/
export function SelectionCheckbox({ selected, className = '' }: SelectionCheckboxProps) {
return (
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'} ${className}`}
>
{selected ? (
<svg viewBox="0 0 16 16" fill="none" className="h-3 w-3 text-white" aria-hidden="true">
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
</span>
);
}
-43
View File
@@ -1,43 +0,0 @@
import { useEffect, useRef } from 'react';
import { Icon } from '@/app/Icon';
import { TOAST_DURATION } from '@/lib/config';
interface ToastProps {
message: string;
variant?: 'success' | 'error';
onDone: () => void;
}
export function Toast({ message, variant = 'success', onDone }: ToastProps) {
// Call sites pass an inline `onDone` arrow, so a plain `[message, onDone]` dependency array
// restarts the auto-dismiss timer on every unrelated parent re-render (e.g. typing in an
// unrelated field after the toast appears) instead of only when a NEW message is shown.
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
});
useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(() => onDoneRef.current(), TOAST_DURATION);
return () => window.clearTimeout(timer);
}, [message]);
if (!message) return null;
return (
<div
role="status"
// `position:fixed` resolves against the viewport, not AppShell's own Outlet box -- below 640px
// that viewport bottom is where the persistent BottomTabBar (82px tall) already sits, so the
// unconditional `bottom-12` this used to be sat squarely on top of it on every phone-width
// screen, for every Toast call site in the app. `sm:bottom-12` restores the original 48px gap
// once the tab bar is gone (>=640px, NavRail shown instead).
className="fixed bottom-[130px] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-2.5 text-sm text-white sm:bottom-12"
>
<Icon name={variant === 'error' ? 'error' : 'check_circle'} className={`text-[18px] ${variant === 'error' ? 'text-danger-text' : 'text-online'}`} />
{message}
</div>
);
}
-229
View File
@@ -1,229 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { ImApiError } from '@/lib/api/client';
import { getUserProfile } from '@/lib/api/friends';
import { getMyProfile } from '@/lib/api/profile';
import { useEscapeKey } from '@/lib/hooks';
import { useCallStore } from '@/stores/calls';
import { useSessionStore } from '@/stores/session';
import { closeUserCard, useUserCardStore } from '@/stores/userCard';
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
/** Mounted once at the app root (App.tsx) -- renders whatever `openUserCard()` (stores/userCard.ts)
* most recently asked for. A lighter, faster-to-reach sibling of FriendProfileView's full `/contacts/
* :userId` page (same `getUserProfile` data source, same relationship-aware action gating), not a
* replacement for it -- "查看完整资料" below still routes there for remark-editing/QR code/etc. */
export function UserCardRoot() {
const userId = useUserCardStore((s) => s.userId);
if (!userId) return null;
// key={userId} so switching straight from one card to another (tapping a different @mention
// before closing the first) resets the query/local state instead of showing stale data mid-fetch.
return <UserCardPanel key={userId} userId={userId} />;
}
function UserCardPanel({ userId }: { userId: string }) {
const navigate = useNavigate();
// Registered here, not in the always-mounted UserCardRoot above: useEscapeKey's stack (lib/hooks.ts)
// orders entries by MOUNT time, so an entry registered once at app boot would sit permanently at
// the bottom, below every modal that mounts later -- a real, reproduced bug (open GroupMembersModal
// -> tap a member row to open this card on top of it -> Escape closed the member-list modal
// underneath instead of this card). UserCardPanel only mounts while a card is genuinely open, same
// as every other overlay's own useEscapeKey call, so this now stacks correctly.
useEscapeKey(closeUserCard);
const myUserId = useSessionStore((s) => s.myUserId);
const isSelf = !!myUserId && myUserId === userId;
const [addingFriend, setAddingFriend] = useState(false);
// Real, live-confirmed bug (2026-09-25 review): `GET /users/{id}/profile` 400s on a SELF lookup,
// same self-lookup gap already documented and fixed elsewhere for the group-scoped equivalent
// (getMyGroupProfile's own comment, `lib/api/groups.ts`) -- tapping your OWN avatar (reachable from
// the member grid/list like anyone else's) always landed on the "加载失败,请重试" branch, retrying
// into the same 400 forever. Routes self through the dedicated `GET /me/profile` instead, which has
// no such restriction; normalized into the same shape the render below already expects so the two
// branches don't need two separate JSX trees.
const otherQuery = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => getUserProfile(userId),
enabled: !isSelf,
});
const selfQuery = useQuery({
queryKey: ['my-profile-card', userId],
queryFn: () => getMyProfile(),
enabled: isSelf,
});
const query = isSelf ? selfQuery : otherQuery;
const profile = isSelf
? selfQuery.data
? {
user_id: userId,
display_name: selfQuery.data.nickname,
avatar_file_id: selfQuery.data.avatar_file_id,
gender: selfQuery.data.gender,
region: selfQuery.data.region,
signature: selfQuery.data.signature,
public_account: selfQuery.data.public_account,
relationship: 'self',
remark: undefined as string | undefined,
capabilities: undefined as { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean } | undefined,
}
: undefined
: otherQuery.data;
const name = profile ? profile.remark || profile.display_name : '';
// Real bug this fixes: this card's own doc comment always claimed "same relationship-aware action
// gating" as FriendProfileView.tsx, but until now it never read `profile.relationship` at all --
// tapping a non-friend's avatar (this card is reachable from many more places than the friends-only
// surfaces FriendProfileView is, e.g. any group member's avatar or an @mention) showed an enabled
// "发消息"/call row with no add-friend affordance anywhere, gated only by `capabilities.can_send`/
// `can_call` -- a signal ConversationView.tsx's own comment already documents as unreliable for
// exactly this purpose (it 403s from a blocked party's own side instead of reporting false). Mirrors
// FriendProfileView's own isFriend/isPendingOutbound/isPendingInbound branching exactly, just at
// this card's single (not two-size-variant) button size.
const isFriend = profile?.relationship === 'friend';
const isPendingOutbound = profile?.relationship === 'pending_outbound';
const isPendingInbound = profile?.relationship === 'pending_inbound';
const goToFullProfile = () => {
closeUserCard();
navigate(isSelf ? '/settings/profile' : `/contacts/${userId}`);
};
return (
<>
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={closeUserCard}>
<div className="w-[300px] overflow-hidden rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end px-3 pt-3">
<button
type="button"
onClick={closeUserCard}
className="grid h-7 w-7 place-items-center rounded-full text-text-tertiary-2 hover:bg-surface-input"
>
<Icon name="close" className="text-[18px]" />
</button>
</div>
{query.isLoading ? (
<div className="flex h-44 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : !profile ? (
<div className="flex h-44 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-text-tertiary-4">
{query.error instanceof ImApiError && query.error.status === 403 ? (
'对方设置了资料保护,暂时无法查看'
) : query.error instanceof ImApiError && query.error.status === 404 ? (
'未找到该用户'
) : (
<>
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</>
)}
</div>
) : (
<div className="flex flex-col items-center px-6 pb-6">
<Avatar id={profile.user_id} name={name} avatarFileId={profile.avatar_file_id} className="h-18 w-18 flex-none rounded-2xl text-xl" />
<strong className="mt-3 max-w-full truncate text-base font-semibold text-text-heading">{name}</strong>
{profile.remark ? <span className="mt-0.5 max-w-full truncate text-[13px] text-text-tertiary-2">昵称:{profile.display_name}</span> : null}
{profile.gender || profile.region ? (
<span className="mt-0.5 text-[13px] text-text-tertiary-2">
{[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')}
</span>
) : null}
{profile.signature ? <p className="m-0 mt-2 line-clamp-3 text-center text-sm text-text-secondary">{profile.signature}</p> : null}
{!isSelf ? (
<div className="mt-5 flex w-full items-center gap-2">
{isFriend ? (
<>
<button
type="button"
disabled={profile.capabilities?.can_send === false}
onClick={() => {
closeUserCard();
navigate(`/messages/${profile.user_id}`);
}}
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:opacity-50"
>
<Icon name="chat_bubble" className="text-base" />
发消息
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase !== 'idle') return;
closeUserCard();
useCallStore.getState().startCall(profile.user_id, name, 'audio');
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
>
<Icon name="call" className="text-base" />
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase !== 'idle') return;
closeUserCard();
useCallStore.getState().startCall(profile.user_id, name, 'video');
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
>
<Icon name="videocam" className="text-base" />
</button>
</>
) : isPendingOutbound ? (
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
等待验证
</button>
) : isPendingInbound ? (
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
对方已申请加你
</button>
) : profile.capabilities?.can_add_friend === false ? (
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
对方暂不可添加
</button>
) : (
<button
type="button"
onClick={() => setAddingFriend(true)}
className="h-9 flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm"
>
添加好友
</button>
)}
</div>
) : null}
<button type="button" onClick={goToFullProfile} className="mt-3 text-[13px] text-accent">
查看完整资料
</button>
</div>
)}
</div>
</div>
{/* A true sibling of the card's own backdrop above, not nested inside it: AddFriendModal is
itself a dismissible `fixed inset-0` overlay with its own backdrop-click-to-close handler,
and React events bubble -- nesting it inside the card's backdrop (which ALSO closes on any
click) would make clicking outside AddFriendModal close both overlays at once instead of
just the top one. */}
{addingFriend ? (
<AddFriendModal
onClose={() => setAddingFriend(false)}
initialTarget={
profile
? { user_id: profile.user_id, display_name: profile.display_name, public_account: profile.public_account, avatar_file_id: profile.avatar_file_id }
: undefined
}
/>
) : null}
</>
);
}
@@ -1,161 +0,0 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { ImApiError } from '@/lib/api/client';
import { submitTenantApplication, uploadPublicFile } from '@/lib/api/tenant';
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** README §10.5 / old app's EnterpriseRegister -- a company registering itself as a new tenant. */
export function EnterpriseRegisterPage() {
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
const [creditCode, setCreditCode] = useState('');
const [email, setEmail] = useState('');
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [reviewDays, setReviewDays] = useState(1);
const [toast, setToast] = useState('');
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!CREDIT_CODE_RE.test(creditCode)) return setError('请输入 18 位统一社会信用代码');
if (email && !EMAIL_RE.test(email)) return setError('请输入有效的企业邮箱');
setSubmitting(true);
setError('');
try {
let licenseFileId: string | undefined;
if (file) {
licenseFileId = (await uploadPublicFile(file, 'enterprise_license')).file_id;
}
const data = await submitTenantApplication({
credit_code: creditCode,
enterprise_email: email || undefined,
license_file_id: licenseFileId,
tenant_code_hint: tenantCodeHint,
});
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
setSubmitted(true);
setToast('提交成功,我们会尽快联系你');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full flex-col items-center overflow-y-auto bg-surface-page px-6 py-10">
<div className="w-full max-w-[420px]">
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回
</Link>
<h1 className="mb-6 mt-4 text-[22px] font-bold text-text-title">企业注册</h1>
<ol className="mb-6 flex items-center text-xs text-text-tertiary-3">
{(['填写企业信息', '确定信息', '提交审核'] as const).map((label, i) => (
<li key={label} className="flex flex-1 items-center last:flex-none">
<span
className={`grid h-[22px] w-[22px] flex-none place-items-center rounded-full text-[11px] ${
i === 0 || submitted ? 'bg-accent text-white' : 'border border-[#d5d9df] text-text-tertiary-3'
}`}
>
{i + 1}
</span>
<span className={`ml-1.5 whitespace-nowrap ${i === 0 || submitted ? 'text-text-body' : ''}`}>{label}</span>
{i < 2 ? <span className={`mx-2 h-px flex-1 ${submitted ? 'bg-accent' : 'bg-[#d5d9df]'}`} /> : null}
</li>
))}
</ol>
<form onSubmit={submit} className="flex flex-col gap-4">
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">统一社会信用代码</label>
<div className="flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<input
value={creditCode}
onChange={(e) => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码"
maxLength={18}
className="w-full bg-transparent text-[15px] outline-none"
/>
<Icon name="document_scanner" className="flex-none text-[20px] text-focus-border" />
</div>
<p className="m-0 text-xs text-text-tertiary-3">请填写营业执照上的统一社会信用代码</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">
营业执照 <span className="text-text-tertiary-3">选填</span>
</label>
<label className="flex h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2]">
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
onChange={(e) => {
const selected = e.target.files?.[0];
e.target.value = '';
if (!selected) return;
// `accept` is only a file-picker hint, not enforcement -- a user can still pick
// an oversized or wrong-type file via "所有文件". Matching TenantFeedbackPage's
// own screenshot validation rather than silently uploading and waiting on the
// backend's FILE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE rejection for the same check.
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
setError('营业执照上传失败:仅支持 JPG/PNG/WebP/PDF,且不超过 10MB');
return;
}
setError('');
setFile(selected);
}}
className="hidden"
/>
<Icon name="upload_file" className="text-[22px] text-accent" />
<span className="text-sm text-text-body">{file ? file.name : '点击上传营业执照'}</span>
<span className="text-xs text-text-tertiary-3">支持 JPG、PNG、WebP、PDF,不超过 10MB</span>
</label>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">
企业邮箱 <span className="text-text-tertiary-3">选填</span>
</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
placeholder="请输入企业邮箱"
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
/>
<p className="m-0 text-xs text-text-tertiary-3">用于接收审核结果及企业通知</p>
</div>
<button
type="submit"
disabled={submitted || submitting || creditCode.length !== 18}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitted ? '已提交' : submitting ? '提交中…' : '提交审核'}
</button>
<p className="m-0 flex items-center justify-center gap-1 text-xs text-text-tertiary-3">
<Icon name="schedule" className="text-[16px]" />
资料提交后,预计 {reviewDays} 个工作日完成审核
</p>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}
-123
View File
@@ -1,123 +0,0 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { BASE_APP_NAME } from '@/lib/config';
import { getTinode, loginBasic } from '@/lib/tinode/connection';
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { useSessionStore } from '@/stores/session';
export function LoginPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
// Real login failures (wrong password, disabled account, ...) clear the password and keep the
// account, matching the product spec -- ported from the old app's own effect for this.
useEffect(() => {
if (error) setPassword('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
if (!tenant) {
// Read tenant before touching getTinode() -- getTinode() lazily creates and PERMANENTLY caches
// the singleton off whatever tenant is in the store at that moment (or a hardcoded fallback
// host if there's none yet); calling it here first would risk creating that singleton against
// the wrong tenant before this redirect even happens.
return <Navigate to="/tenant" replace />;
}
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!login.trim() || !password || submitting) return;
setSubmitting(true);
setError('');
try {
const tinode = getTinode();
const ctrl = await loginBasic(login.trim(), password);
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
return;
}
const authToken = tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId(tinode.getCurrentUserID() || null);
navigate('/messages');
} catch (err) {
setError(loginErrorMessage(err));
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full">
<div className="relative hidden shrink-0 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex min-[900px]:w-[56%]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
<div className="absolute left-16 top-14 flex items-center gap-2.5">
<img src="/app-icon.png" alt="" className="h-10 w-10 rounded-[10px]" />
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
</div>
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
企业内部沟通,安全、即时、多端同步。
</h2>
<p className="mt-4 max-w-md text-base text-white/80">
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
</p>
</div>
<div className="flex flex-1 flex-col bg-white px-6 py-8">
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
切换企业
</Link>
<div className="flex flex-1 flex-col items-center justify-center">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="mb-1 flex flex-col items-center gap-3 min-[900px]:hidden">
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
</div>
<div className="hidden flex-col gap-1 min-[900px]:flex">
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
</div>
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
placeholder="请输入用户名/手机号"
autoComplete="username"
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
/>
<PasswordInput value={password} onChange={setPassword} placeholder="请输入密码" autoComplete="current-password" />
<Link to="/reset-password" className="-mt-2 self-end text-sm text-accent no-underline">
忘记密码
</Link>
<button
type="submit"
disabled={!login.trim() || !password || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '登录中…' : '登录'}
</button>
<p className="m-0 text-center text-sm text-text-secondary">
还没有账号?<Link to="/register" className="text-accent no-underline">注册账号</Link>
</p>
</form>
</div>
</div>
</div>
);
}
-191
View File
@@ -1,191 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
import { verifyRegistrationCode } from '@/lib/api/auth';
import { fileBase, ImApiError } from '@/lib/api/client';
import { getTinode, registerBasic } from '@/lib/tinode/connection';
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { useSessionStore } from '@/stores/session';
const USERNAME_RE = /^[a-z0-9]{3,10}$/;
const PHONE_RE = /^\d{11}$/;
const CODE_RE = /^(?:\d{6}|[A-Za-z]{5})$/;
function normalizePhone(raw: string): string {
const digits = raw.replace(/\D/g, '');
return (digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits).slice(0, 11);
}
export function RegisterPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [username, setUsername] = useState('');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [challengeId, setChallengeId] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [agreed, setAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [legalDocument, setLegalDocument] = useState<'terms' | 'privacy' | null>(null);
const legalDialogRef = useRef<HTMLDialogElement>(null);
const verifiedRef = useRef<{ key: string; response: string } | null>(null);
const submitRef = useRef(false);
useEffect(() => {
const dialog = legalDialogRef.current;
if (!dialog) return;
if (legalDocument && !dialog.open) dialog.showModal();
if (!legalDocument && dialog.open) dialog.close();
}, [legalDocument]);
if (!tenant) return <Navigate to="/tenant" replace />;
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
// The deployed file endpoint is pinned to the IM API host; the web host serves
// unknown /legal/* paths as the SPA index instead of the actual agreements.
const legalOrigin = new URL(fileBase(), window.location.href).origin;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (submitRef.current) return;
setError('');
if (!username && !phone) return setError('请填写用户名或手机号');
if (username && !USERNAME_RE.test(username)) return setError('用户名须为 3~10 位小写字母或数字');
if (phone && !PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
if (phone && (!challengeId || !CODE_RE.test(code))) return setError('请先获取验证码并填写 6 位短信验证码或 5 位邀请码');
if (password.length < minPasswordLength || (tenant.passwordPolicy?.maxLength && password.length > tenant.passwordPolicy.maxLength)) {
return setError(`密码长度须为 ${minPasswordLength}~${tenant.passwordPolicy?.maxLength ?? 64} 位`);
}
if (password !== confirm) return setError('两次输入的密码不一致');
if (!agreed) return setError('请先阅读并同意用户条款与隐私政策');
submitRef.current = true;
setSubmitting(true);
try {
let credential: { meth: string; val: string; resp: string; params: { challenge_id: string } } | undefined;
if (phone) {
const key = `${phone}:${challengeId}:${code}`;
let response = verifiedRef.current?.key === key ? verifiedRef.current.response : '';
if (!response) {
const verified = await verifyRegistrationCode(challengeId, code);
response = verified.verification_mode === 'invite_code' ? code : verified.verification_token || '';
if (!response) throw new Error('验证结果无效,请重新获取验证码');
verifiedRef.current = { key, response };
}
credential = { meth: 'tel', val: phone, resp: response, params: { challenge_id: challengeId } };
}
const ctrl = await registerBasic({ username, phone, password, tenantCode: tenant.code, credential });
if (ctrl.code >= 300) {
setError(ctrl.text === 'validate credentials'
? validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)
: '注册失败,请检查资料后重试');
return;
}
const tinode = getTinode();
const token = tinode.getAuthToken()?.token;
const userId = tinode.getCurrentUserID();
if (!token || !userId) throw new Error('账号已创建,请返回登录页登录');
sessionStorage.setItem('im-session-token', token);
setMyUserId(userId);
navigate('/messages', { replace: true });
} catch (err) {
setError(err instanceof ImApiError ? err.message : loginErrorMessage(err));
} finally {
submitRef.current = false;
setSubmitting(false);
}
};
return (
<div className="flex min-h-dvh flex-col bg-surface-page">
<header className="flex h-14 items-center border-b border-border-panel bg-white px-5">
<Link to="/login" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />返回登录
</Link>
<span className="ml-auto text-sm text-text-secondary">{tenant.name}</span>
</header>
<main className="flex flex-1 justify-center px-5 py-8">
<form onSubmit={submit} className="flex w-full max-w-[440px] flex-col gap-4">
<h1 className="m-0 text-[24px] font-semibold text-text-title">注册账号</h1>
{error && <div role="alert" className="rounded-md bg-error-strip-bg px-3 py-2 text-sm text-error-strip-text">{error}</div>}
<label className="flex flex-col gap-1 text-sm text-text-secondary">
用户名
<input value={username} onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))}
placeholder="3~10 位小写字母或数字,可不填" autoComplete="username"
className="h-[50px] rounded-md bg-surface-input px-3.5 text-[15px] text-text-body outline-none focus:border focus:border-focus-border" />
</label>
<label className="flex flex-col gap-1 text-sm text-text-secondary">
手机号
<input value={phone} onChange={(e) => {
const nextPhone = normalizePhone(e.target.value);
if (nextPhone !== phone) {
setPhone(nextPhone);
setChallengeId('');
setCode('');
verifiedRef.current = null;
}
}} placeholder="11 位手机号,可不填"
type="tel" inputMode="numeric" autoComplete="tel"
className="h-[50px] rounded-md bg-surface-input px-3.5 text-[15px] text-text-body outline-none focus:border focus:border-focus-border" />
</label>
{phone && <div className="flex min-w-0 items-end gap-2">
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm text-text-secondary">
验证码或邀请码
<input value={code} onChange={(e) => { setCode(e.target.value.slice(0, 6)); verifiedRef.current = null; }}
placeholder="6 位短信码 / 5 位邀请码" autoComplete="one-time-code"
className="h-[50px] w-full rounded-md bg-surface-input px-3.5 text-[15px] text-text-body outline-none focus:border focus:border-focus-border" />
</label>
<div className="flex h-[50px] items-center">
<VerificationCodeButton phone={phone} scene="register" tenantCode={tenant.code}
onChallenge={(id) => { setChallengeId(id); setCode(''); verifiedRef.current = null; }} onError={setError} />
</div>
</div>}
{!phone && username && <p className="m-0 text-xs text-text-secondary">未绑定手机号时无法自助找回密码,请联系企业管理员重置。</p>}
<label className="text-sm text-text-secondary">密码</label>
<PasswordInput value={password} onChange={setPassword} placeholder={`至少 ${minPasswordLength} 位密码`}
autoComplete="new-password" maxLength={tenant.passwordPolicy?.maxLength} />
<label className="text-sm text-text-secondary">确认密码</label>
<PasswordInput value={confirm} onChange={setConfirm} placeholder="再次输入密码"
autoComplete="new-password" maxLength={tenant.passwordPolicy?.maxLength} />
<label className="flex items-start gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} className="mt-1" />
<span>我已阅读并同意 <button type="button" onClick={() => setLegalDocument('terms')} className="cursor-pointer p-0 text-accent">用户条款</button> 和 <button type="button" onClick={() => setLegalDocument('privacy')} className="cursor-pointer p-0 text-accent">隐私政策</button></span>
</label>
<button type="submit" disabled={submitting || !agreed || (!username && !phone)}
className="h-[50px] rounded-md bg-btn text-base font-medium text-white disabled:bg-btn-disabled">
{submitting ? '注册中…' : '注册并登录'}
</button>
</form>
</main>
<dialog
ref={legalDialogRef}
onClose={() => setLegalDocument(null)}
className="m-auto h-[min(88dvh,800px)] w-[min(92vw,800px)] overflow-hidden rounded-md border border-border-panel bg-white p-0 shadow-xl backdrop:bg-black/50"
aria-label={legalDocument === 'privacy' ? '隐私政策' : '用户条款'}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border-panel px-5">
<h2 className="m-0 text-base font-semibold text-text-title">{legalDocument === 'privacy' ? '隐私政策' : '用户条款'}</h2>
<button type="button" onClick={() => legalDialogRef.current?.close()} aria-label="关闭协议弹窗" title="关闭"
className="grid h-9 w-9 place-items-center text-text-secondary hover:text-text-title">
<Icon name="close" className="text-[20px]" />
</button>
</div>
{legalDocument && <iframe
key={legalDocument}
title={legalDocument === 'privacy' ? '隐私政策正文' : '用户条款正文'}
src={`${legalOrigin}/legal/${legalDocument === 'privacy' ? 'privacy-policy' : 'user-terms'}`}
referrerPolicy="no-referrer"
className="min-h-0 w-full flex-1 border-0 bg-white"
/>}
</div>
</dialog>
</div>
);
}
-174
View File
@@ -1,174 +0,0 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
import { useSessionStore } from '@/stores/session';
const PHONE_RE = /^\d{11}$/;
/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a
* 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently
* drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */
function normalizePhoneInput(raw: string): string {
const digits = raw.replace(/\D/g, '');
const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits;
return withoutCountryCode.slice(0, 11);
}
export function ResetPasswordPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [challengeId, setChallengeId] = useState('');
const [error, setError] = useState('');
// README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE
// field error under the password input, not just the shared top banner every other validation
// failure here uses -- this mirrors that without touching the other (phone/code) failure paths.
const [passwordFieldError, setPasswordFieldError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState('');
// A challengeId/code pair is only valid for the phone number it was issued for -- without this,
// editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that
// verifyPasswordResetCode() has no way to cross-check against the NEW phone in the field.
useEffect(() => {
setChallengeId('');
setCode('');
}, [phone]);
if (!tenant) return <Navigate to="/tenant" replace />;
// Real per-tenant policy (docs/IM-前后端对接文档.md:534) -- was hardcoded to 8 here, not because
// anything ever read the real value; SecuritySettingsPage's in-place change had drifted to a
// DIFFERENT hardcoded 6.
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordFieldError('');
setError('');
if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码');
// Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy
// only enforces a minimum length, so the message says exactly that and nothing more). Inline-only
// (no setError) -- these two are the password fields' OWN validation, already shown right under
// the relevant input; also raising the shared top banner with the identical text duplicated the
// same message on screen twice for no reason phone/code's genuinely-separate failures don't share.
if (password.length < minPasswordLength) {
return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`);
}
if (password !== confirm) {
return setPasswordFieldError('两次输入的密码不一致');
}
setSubmitting(true);
setError('');
try {
const verified = await verifyPasswordResetCode(challengeId, code);
await confirmPasswordReset(verified.reset_token, password);
setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('/login'), 1200);
} catch (err) {
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link
scaled down. */}
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</Link>
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">重置密码</strong>
</div>
<Link to="/login" className="hidden items-center gap-1 px-6 pt-8 text-sm text-text-secondary no-underline min-[900px]:flex">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回登录
</Link>
<div className="flex flex-1 items-center justify-center px-6 py-8">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="m-0 hidden text-[22px] font-bold text-text-title min-[900px]:block">重置密码</h1>
<p className="m-0 text-sm text-text-tertiary-1">
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
</p>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="请输入手机号"
type="tel"
inputMode="numeric"
autoComplete="tel"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<div className="flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="短信验证码"
inputMode="numeric"
className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
/>
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
</div>
<PasswordInput
value={password}
onChange={(v) => {
setPassword(v);
setPasswordFieldError('');
}}
placeholder="新密码"
autoComplete="new-password"
error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<PasswordInput
value={confirm}
onChange={(v) => {
setConfirm(v);
setPasswordFieldError('');
}}
placeholder="再次输入新密码"
autoComplete="new-password"
error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<button
type="submit"
disabled={!phone || !code || !password || !confirm || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '提交中…' : '确认重置'}
</button>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}
-113
View File
@@ -1,113 +0,0 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { ImApiError } from '@/lib/api/client';
import { resolveTenant } from '@/lib/api/tenant';
import { BASE_APP_NAME } from '@/lib/config';
import { resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
export function TenantEntryPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setTenant = useSessionStore((s) => s.setTenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const myUserId = useSessionStore((s) => s.myUserId);
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!code.trim() || submitting) return;
// Reachable by navigating back here while already logged in (no route guard keeps an
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
// Zustand value that resets to null on any fresh mount of this module (a page reload while
// sitting on /tenant, or opening this URL directly in a new tab) even though the session token
// is still sitting in sessionStorage and would otherwise restore fine -- confirmed by reading
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
// never mounts for this route. Checking the token directly closes that gap.
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
if (hasLiveSession && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
setSubmitting(true);
setError('');
try {
const res = await resolveTenant(code.trim());
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
// Without this, switching tenants mid-session silently keeps talking to the OLD tenant's
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
// createTinode() only ever reads useSessionStore's tenant at that one construction moment.
resetTinode();
// The confirm() dialog above literally promises "切换企业将退出当前登录" -- without these,
// that was a false promise: `myUserId` is a separate module-level store that switching
// tenant never touched, so RequireAuth's `if (myUserId) return <Outlet/>` would let a
// subsequent in-app navigation straight through with the OLD tenant's uid and the OLD
// tenant's sessionStorage token, against a freshly-reset Tinode connection that has never
// authenticated with the NEW tenant at all. Same teardown SettingsLayout's own logout and
// SecuritySettingsPage's account deletion already do.
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
queryClient.clear();
setTenant({
code: res.tenant.code,
name: res.tenant.name,
endpoints: res.endpoints,
host,
secure,
passwordPolicy: res.password_policy
? { minLength: res.password_policy.min_length ?? 8, maxLength: res.password_policy.max_length ?? 64 }
: undefined,
});
navigate('/login');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
<Link to="/enterprise-register" state={{ tenantCodeHint: code.trim() }} className="absolute right-6 top-6 text-sm text-accent no-underline">
企业注册
</Link>
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
<div className="mb-2 flex flex-col items-center gap-3">
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
<strong className="text-[26px] font-bold text-text-title">{BASE_APP_NAME}</strong>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<Icon name="business" className="text-[20px] text-focus-border" />
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入企业码"
maxLength={64}
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<button
type="submit"
disabled={!code.trim() || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '进入中…' : '进入企业'}
</button>
</form>
<Link to="/feedback" state={{ tenantCodeHint: code.trim() }} className="absolute bottom-8 text-sm text-accent no-underline">
遇到问题?
</Link>
</div>
);
}
@@ -1,137 +0,0 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { ImApiError } from '@/lib/api/client';
import { submitTenantEntryFeedback, uploadPublicFile } from '@/lib/api/tenant';
interface PendingImage {
file: File;
url: string;
}
/** README §10.6 / `01/1j`'s 问题反馈, reached from the tenant-code screen's "遇到问题?" link. */
export function TenantFeedbackPage() {
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
const [description, setDescription] = useState('');
const [contact, setContact] = useState('');
const [images, setImages] = useState<PendingImage[]>([]);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState('');
const addImages = (files: FileList | null) => {
const selected = Array.from(files || []).slice(0, 9 - images.length);
const valid = selected.filter((f) => f.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(f.type));
if (valid.length < selected.length) setError('部分图片未添加:仅支持 JPG/PNG/WebP,且不超过 10MB');
setImages((prev) => prev.concat(valid.map((file) => ({ file, url: URL.createObjectURL(file) }))));
};
const removeImage = (i: number) => {
setImages((prev) => {
URL.revokeObjectURL(prev[i].url);
return prev.filter((_, idx) => idx !== i);
});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const length = description.trim().length;
if (length < 10 || length > 500) return setError('问题描述需为 10 至 500 个字符');
setSubmitting(true);
setError('');
try {
const screenshotFileIds: string[] = [];
for (const item of images) {
screenshotFileIds.push((await uploadPublicFile(item.file, 'tenant_entry_feedback')).file_id);
}
await submitTenantEntryFeedback({
description: description.trim(),
tenant_code_hint: tenantCodeHint,
screenshot_file_ids: screenshotFileIds,
contact: contact.trim() || undefined,
});
setToast('提交成功,我们会尽快联系你');
images.forEach((item) => URL.revokeObjectURL(item.url));
setDescription('');
setImages([]);
setContact('');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full flex-col items-center overflow-y-auto bg-surface-page px-6 py-10">
<div className="w-full max-w-[420px]">
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回
</Link>
<h1 className="mb-6 mt-4 text-[22px] font-bold text-text-title">问题反馈</h1>
<form onSubmit={submit} className="flex flex-col gap-4">
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<div className="flex flex-col gap-1">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 500))}
placeholder='企业码输入正确但提示"企业码不存在或已停用"'
rows={5}
className="resize-none rounded-lg bg-surface-input p-3.5 text-[15px] outline-none placeholder:text-text-placeholder-1"
/>
<span className="self-end text-xs text-text-tertiary-3">{description.length}/500</span>
</div>
<div className="grid grid-cols-4 gap-2.5">
{images.map((img, i) => (
<div key={img.url} className="relative aspect-square rounded-lg bg-surface-rail">
<img src={img.url} alt="" className="h-full w-full rounded-lg object-cover" />
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -right-1.5 -top-1.5 grid h-[18px] w-[18px] place-items-center rounded-full bg-white text-text-tertiary-1"
>
<Icon name="cancel" className="text-[18px]" />
</button>
</div>
))}
{images.length < 9 ? (
<label className="flex aspect-square cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2] text-text-tertiary-3">
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(e) => addImages(e.target.files)} className="hidden" />
<Icon name="add_photo_alternate" className="text-[20px]" />
<span className="text-xs">{images.length}/9</span>
</label>
) : null}
</div>
<input
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="联系方式(选填,手机号或邮箱)"
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none placeholder:text-text-placeholder-1"
/>
<button
type="submit"
disabled={description.trim().length < 10 || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '提交中…' : '提交'}
</button>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}
@@ -1,45 +0,0 @@
import { useState } from 'react';
import { Icon } from '@/app/Icon';
interface PasswordInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
autoComplete?: string;
error?: string;
maxLength?: number;
}
export function PasswordInput({ value, onChange, placeholder, autoComplete, error, maxLength }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<div className="flex flex-col gap-1.5">
<div
className={`flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
error ? 'border border-danger-text' : 'has-focus:border-focus-border'
}`}
>
<input
type={visible ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete={autoComplete}
maxLength={maxLength}
className="w-full bg-transparent text-[15px] outline-none"
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? '隐藏密码' : '显示密码'}
className="text-text-placeholder-2"
>
<Icon name={visible ? 'visibility' : 'visibility_off'} className="text-[20px]" />
</button>
</div>
{error ? <div className="text-xs text-danger-border">{error}</div> : null}
</div>
);
}
@@ -1,87 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { requestVerificationCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
const PHONE_RE = /^\d{11}$/;
interface VerificationCodeButtonProps {
phone: string;
scene: 'reset_password' | 'register';
tenantCode?: string;
onChallenge: (challengeId: string) => void;
onError: (message: string) => void;
}
/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */
export function VerificationCodeButton({ phone, scene, tenantCode, onChallenge, onError }: VerificationCodeButtonProps) {
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
const phoneRef = useRef(phone);
// Without this, navigating away mid-countdown (e.g. switching from register back to login)
// leaves the interval running against an unmounted component indefinitely until it naturally
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
useEffect(() => () => window.clearInterval(timerRef.current), []);
// A countdown started for one phone number must not block sending a code to a DIFFERENT one --
// otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still
// counting down for the old number) until the old timer happens to run out on its own.
useEffect(() => {
phoneRef.current = phone;
window.clearInterval(timerRef.current);
setSeconds(0);
}, [phone]);
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
timerRef.current = window.setInterval(() => {
setSeconds((s) => {
if (s <= 1) {
window.clearInterval(timerRef.current);
return 0;
}
return s - 1;
});
}, 1000);
};
const click = async () => {
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
// The phone number this request is actually FOR -- captured now, not read again after the
// `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in
// flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever
// number is now showing, with nothing on screen indicating the mismatch -- the eventual
// verification would silently operate on an account the user never intended.
const requestedPhone = phone;
setBusy(true);
try {
const data = await requestVerificationCode(scene, requestedPhone, tenantCode);
if (requestedPhone !== phoneRef.current) return;
onChallenge(data.challenge_id);
tick(data.resend_after_seconds || 60);
} catch (err) {
if (requestedPhone !== phoneRef.current) return;
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds);
if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id);
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={click}
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
className="h-9 w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
>
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
</button>
);
}
-140
View File
@@ -1,140 +0,0 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads
* as "未接听" from the callee's own history and "已取消" from the caller's. */
function statusLabel(item: CallHistoryItem): string {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
if (result === 'finished') return formatDuration(item.duration_ms);
if (result === 'missed') return incoming ? '未接听' : '已取消';
if (result === 'declined') return '已拒绝';
if (result === 'disconnected') return '异常结束';
return result;
}
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used
* to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening
* a friend's profile does); `topic` (the same opaque `p2p...` form the chat list itself caches once
* a conversation has real history) is what display-name resolution matches against instead -- the
* two are different identifier spaces and matching name lookup against `peer_user_id` was a real,
* live-confirmed bug (Zentao #27) that always fell through to the raw uid.
*/
export function CallHistoryView() {
const navigate = useNavigate();
const contacts = useChatList();
const query = useInfiniteQuery({
queryKey: ['call-history'],
queryFn: ({ pageParam }) => getCallHistory(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
{/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录
(SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention
FavoritesLayout's own mobile header already uses, and the same gap this file didn't have
until now (there was no way back from here at all short of a system back gesture). */}
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[18px]" />
</Link>
<strong className="truncate text-center text-base font-semibold text-text-heading">通话记录</strong>
<span />
</header>
<header className="hidden h-15 flex-none items-center border-b border-border-panel bg-white px-6 md:flex">
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
</header>
<div className="flex flex-col gap-2 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
) : (
items.map((item) => {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
// Mock only reds out a genuine "未接听" (a call that rang and was never answered) --
// "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not
// something the viewer missed and should feel warned about. This used to redden EVERY
// non-'finished' result alike.
const isMissed = result === 'missed' && incoming;
// NOT `c.topic === item.peer_user_id` -- live-confirmed real bug (Zentao #27): `peer_user_id`
// is a real `usr...` id, but an established P2P conversation's `contact.topic` is the SDK's
// renamed, opaque `p2p...` form (see getP2PPeerUserId's own doc comment in topics.ts for the
// full mechanism) -- the two are different identifier spaces and this comparison never
// matched for any call with real history, always falling through to the raw uid. `item.topic`
// is that same `p2p...` form already (confirmed live: identical string to what the chat list
// caches for this conversation), so matching on it directly is the correct, direct fix.
const name = contacts.find((c) => c.topic === item.topic)?.public?.fn || item.peer_user_id;
const ts = item.started_at || item.created_at;
return (
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
<button
type="button"
onClick={() => navigate(`/messages/${item.peer_user_id}`)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(item.peer_user_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
<span className={`flex items-center gap-1 truncate text-xs ${isMissed ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
<Icon name={incoming ? 'call_received' : 'call_made'} className="text-sm" />
{statusLabel(item)}
{ts ? ` · ${formatEventTimestamp(new Date(ts))}` : ''}
</span>
</span>
</button>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(item.peer_user_id, name, item.call_type);
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent"
>
<Icon name={item.call_type === 'video' ? 'videocam' : 'call'} className="text-lg" />
</button>
</div>
);
})
)}
{query.hasNextPage ? (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="py-2 text-center text-sm text-accent disabled:opacity-60"
>
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</div>
</div>
);
}
-437
View File
@@ -1,437 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatCallDuration } from '@/lib/messages/dispatch';
import { useCallStore } from '@/stores/calls';
function useElapsedSeconds(connectedAt: number | null): number {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!connectedAt) {
setElapsed(0);
return undefined;
}
const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000));
tick();
const timer = window.setInterval(tick, 1000);
return () => window.clearInterval(timer);
}, [connectedAt]);
return elapsed;
}
function formatElapsed(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function CircleButton({
icon,
active,
danger,
label,
onClick,
size = 'h-13 w-13',
iconSize = 'text-2xl',
}: {
icon: string;
active?: boolean;
danger?: boolean;
label: string;
onClick: () => void;
size?: string;
iconSize?: string;
}) {
return (
<button type="button" onClick={onClick} aria-label={label} className="flex flex-col items-center gap-1.5">
<span
className={`grid ${size} place-items-center rounded-full ${iconSize} text-white ${
danger ? 'bg-[#f34b46]' : active ? 'bg-white !text-[#22262d]' : 'bg-white/[0.16]'
}`}
>
<Icon name={icon} />
</span>
<span className="text-xs text-white">{label}</span>
</button>
);
}
/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone-
* width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with
* no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a
* 16px gutter on each side below the point where the fixed width alone would fit. */
function IncomingCallCard() {
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
return (
<div className="fixed right-4 top-4 z-[60] w-[360px] max-w-[calc(100vw-32px)] rounded-[14px] bg-white p-5 shadow-[0_16px_48px_rgba(0,0,0,0.2)] sm:right-6 sm:top-6">
<div className="flex items-center gap-3">
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<div className="flex min-w-0 flex-col">
<strong className="truncate text-[15px] font-semibold text-text-heading">{peerName}</strong>
<span className="text-xs text-text-tertiary-2">邀请你{callType === 'video' ? '视频' : '语音'}通话</span>
</div>
</div>
<div className="mt-4 flex justify-end gap-3">
<button type="button" onClick={rejectCall} aria-label="拒绝" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#f34b46] text-xl text-white">
<Icon name="call_end" />
</button>
<button type="button" onClick={acceptCall} aria-label="接听" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#1fb36a] text-xl text-white">
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
</button>
</div>
</div>
);
}
/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout
* serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small
* top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two
* per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard`
* because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */
function MobileIncomingCall() {
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
<span
className={`mt-32 grid h-[120px] w-[120px] place-items-center rounded-[28px] text-[46px] font-medium text-white ${avatarBgClass(topic || peerName)}`}
style={{ boxShadow: '0 0 0 14px rgba(43,182,115,0.18), 0 0 0 32px rgba(43,182,115,0.08)' }}
>
{avatarLetter(peerName)}
</span>
<strong className="mt-8 text-2xl font-semibold">{peerName}</strong>
<span className="mt-2 text-[15px] text-white/70">邀请你{callType === 'video' ? '视频' : '语音'}通话…</span>
<div className="mb-16 mt-auto flex gap-24">
<button type="button" onClick={rejectCall} aria-label="拒绝" className="flex flex-col items-center gap-2.5 text-[13px]">
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#f34b46] text-[32px]">
<Icon name="call_end" />
</span>
拒绝
</button>
<button type="button" onClick={acceptCall} aria-label="接听" className="flex flex-col items-center gap-2.5 text-[13px]">
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#1fb36a] text-[32px]">
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
</span>
接听
</button>
</div>
</div>
);
}
/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) --
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
* unmounted between full-screen and minimized). */
function MinimizedCallBar() {
const { topic, peerName, connectedAt, mediaJoined, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
const elapsed = useElapsedSeconds(connectedAt);
return (
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
// and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of
// AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width,
// there's no `sm:hidden` gate on this component to make the overlap moot).
<div className="fixed bottom-[106px] right-6 z-[60] flex w-[280px] items-center gap-3 rounded-xl bg-[#1b2231] px-4 py-3 text-white sm:bottom-6">
<button type="button" onClick={toggleMinimize} aria-label="展开通话窗口" className="flex flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium">{peerName}</strong>
{/* `mediaJoined`, not bare `connectedAt` -- signaling accept flips `connectedAt` before
media has actually joined (see stores/calls.ts's own comment on this field), so this
used to claim "通话中" for a call whose mic never actually attached. */}
<span className="text-xs text-success">{connectedAt && mediaJoined ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
</span>
</button>
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
<Icon name={muted ? 'mic_off' : 'mic'} className="text-base" />
</button>
<button type="button" onClick={hangUp} aria-label="挂断" className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f34b46]">
<Icon name="call_end" className="text-base" />
</button>
</div>
);
}
/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the
* design calls for one) -- the old app's own real CallPanel is also just an overlay, never a
* separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a
* cross-window signaling bus this rewrite has no other need for. */
function CallWindow() {
const {
topic,
callType,
phase,
peerName,
connectedAt,
mediaJoined,
muted,
cameraOff,
hasRemoteVideo,
hasLocalVideo,
minimized,
mediaError,
toggleMinimize,
toggleMute,
toggleCamera,
hangUp,
} = useCallStore();
const remoteRef = useRef<HTMLDivElement>(null);
const localRef = useRef<HTMLDivElement>(null);
const elapsed = useElapsedSeconds(connectedAt);
useEffect(() => {
if (remoteRef.current) playRemoteVideo(remoteRef.current);
}, [hasRemoteVideo]);
useEffect(() => {
if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current);
}, [hasLocalVideo, cameraOff]);
if (minimized) return <MinimizedCallBar />;
// Signaling `connected` and actual media both being up are two different things (see stores/
// calls.ts's `mediaJoined` comment) -- a real "网络良好" claim needs the second one too, and the
// brief signaling-only window in between gets its own label rather than either overstating the
// connection or reusing the pre-answer ringing text.
const statusLabel = phase === 'connected' ? (mediaJoined ? `${formatElapsed(elapsed)} · 网络良好` : '正在连接中…') : '正在等待对方接听…';
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
const hangUpLabel = phase === 'connected' ? '挂断' : '取消';
if (callType === 'audio') {
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-between bg-gradient-to-b from-[#1f2a3d] to-[#111826] py-16 text-white">
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="absolute left-5 top-5 text-white/70">
<Icon name="expand_more" className="text-2xl" />
</button>
<div className="flex flex-col items-center gap-4">
<span
// Mock `06-calls.dc.html #1b`: `border-radius:24px` (a squircle), not a perfect circle --
// the mobile equivalent of this same avatar (MobileIncomingCall) already gets this right.
className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
style={{ boxShadow: '0 0 0 12px rgba(43,182,115,0.18), 0 0 0 28px rgba(43,182,115,0.08)' }}
>
{avatarLetter(peerName)}
</span>
<strong className="text-center text-xl font-semibold">{peerName}</strong>
<span className="text-sm text-white/60">{statusLabel}</span>
</div>
<div className="flex gap-8">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
</div>
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
</div>
);
}
return (
<div
className="fixed inset-0 z-[60] text-white"
style={{ background: 'radial-gradient(ellipse at 50% 40%, #3b4a63, #1b2231)' }}
>
{/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10`
(here and on every other overlay piece below) is deliberate, not decorative -- without it,
this and the real `remoteRef` video div right below are BOTH `position:absolute` with no
z-index, so plain DOM order would decide stacking and the video (which comes second) would
paint over this header the moment real remote video is actually playing. That case never
got exercised live (this sandbox has no real camera, see the calls verification note in the
plan file), so it's a latent bug worth closing here rather than leaving for whoever first
tests a real 2-camera call to rediscover. */}
<div className="absolute inset-x-0 top-0 z-10 hidden items-center justify-between bg-gradient-to-b from-black/40 to-transparent px-6 py-4 md:flex">
<div className="flex flex-col">
<strong className="text-base font-semibold">{peerName}</strong>
<span className="flex items-center gap-1 text-xs text-white/70">
{phase === 'connected' ? (
mediaJoined ? (
<>
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
</>
) : (
'正在连接中…'
)
) : (
'正在等待对方接听…'
)}
</span>
</div>
<div className="flex items-center gap-3 text-white/80">
<button type="button" onClick={toggleMinimize} aria-label="最小化">
<Icon name="picture_in_picture_alt" className="text-xl" />
</button>
</div>
</div>
{/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text --
those move into the centered overlay below (no remote video yet) or the small pill just
under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom
control grid, matching the mock exactly. */}
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 py-1.5 md:hidden">
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="grid h-10 w-10 place-items-center">
<Icon name="picture_in_picture_alt" className="text-xl" />
</button>
<button
type="button"
onClick={() => void useCallStore.getState().switchCamera()}
aria-label="翻转摄像头"
className="grid h-10 w-10 place-items-center"
>
<Icon name="flip_camera_ios" className="text-xl" />
</button>
</div>
{hasRemoteVideo ? (
<div className="absolute left-1/2 top-16 z-10 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/40 px-3 py-1 text-xs md:hidden">
{peerName}
{phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''}
</div>
) : null}
<div className="absolute inset-0">
{hasRemoteVideo ? (
<div ref={remoteRef} className="h-full w-full [&>video]:h-full [&>video]:w-full [&>video]:object-cover" />
) : (
<>
{/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */}
<div className="hidden h-full w-full place-items-center md:grid">
<div className="flex flex-col items-center gap-3">
{/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */}
<span
className={`grid h-[120px] w-[120px] place-items-center rounded-[28px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
>
{avatarLetter(peerName)}
</span>
<span className="text-sm text-white/70">{phase === 'connected' && mediaJoined ? '对方已关闭摄像头' : ''}</span>
</div>
</div>
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
has no header info bar to carry the name instead. */}
<div className="flex h-full w-full flex-col items-center pt-[150px] md:hidden">
<span
className={`grid h-[110px] w-[110px] place-items-center rounded-[26px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
>
{avatarLetter(peerName)}
</span>
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
<span className="mt-1 text-sm text-white/70">
{phase === 'connected' ? (mediaJoined ? formatElapsed(elapsed) : '正在连接中…') : '正在等待对方接听…'}
</span>
{/* Desktop's version of this same fallback state shows this explanation (line 285
above); mobile silently dropped it, showing only the elapsed-time line with no
indication of WHY there's no video. */}
{phase === 'connected' && mediaJoined ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
</div>
</>
)}
</div>
{/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track
stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain
responsive classes, restoring the desktop values under `md:`. */}
{!cameraOff ? (
<div
ref={localRef}
className="absolute right-4 top-[110px] z-10 h-40 w-27 overflow-hidden rounded-[14px] border-2 border-white/30 bg-black/40 [&>video]:h-full [&>video]:w-full [&>video]:object-cover md:right-5 md:top-[72px] md:h-[132px] md:w-[200px] md:rounded-xl"
/>
) : null}
{/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */}
<div className="absolute inset-x-0 bottom-8 z-10 hidden justify-center gap-6 md:flex">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
<CircleButton icon={cameraOff ? 'videocam_off' : 'videocam'} active={cameraOff} label="摄像头" onClick={toggleCamera} />
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
<CircleButton icon="screen_share" label="共享屏幕" onClick={() => {}} />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
</div>
{/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and
bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */}
<div className="absolute inset-x-0 bottom-12 z-10 grid grid-cols-3 gap-y-6 px-8 md:hidden">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} size="h-15 w-15" iconSize="text-[26px]" />
<CircleButton
icon={cameraOff ? 'videocam_off' : 'videocam'}
active={cameraOff}
label="摄像头"
onClick={toggleCamera}
size="h-15 w-15"
iconSize="text-[26px]"
/>
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} size="h-15 w-15" iconSize="text-[26px]" />
<span />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} size="h-17 w-17" iconSize="text-[30px]" />
<span />
</div>
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
</div>
);
}
/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any
* call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's
* callResultDisplay) so the live moment and the message history agree on how a call is described. */
function EndedCallOverlay() {
const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore();
// Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s
// window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each
// render would make the displayed value drift depending on when React happens to re-render.
const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0));
const icon = callType === 'video' ? 'videocam' : 'call';
const { label, danger } = (() => {
switch (endReason) {
case 'finished':
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
case 'missed':
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
// `wasCaller` here is `false` exactly when this device is the one that just clicked
// "拒绝" (rejectCall() only runs during `phase==='incoming'`) -- same distinction `missed`
// right above already makes, "对方已拒绝" is backwards on the decliner's own screen.
case 'declined':
return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true };
case 'disconnected':
return { label: '通话连接异常,已结束', danger: true };
case 'busy':
return { label: '对方忙线中', danger: true };
default:
return { label: '通话已结束', danger: false };
}
})();
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
{/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family --
no mock draws this exact result-overlay state, so this follows the nearest one that does). */}
<span className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<strong className="text-xl font-semibold">{peerName}</strong>
<span className={`flex items-center gap-1.5 text-sm ${danger ? 'text-[#f34b46]' : 'text-white/70'}`}>
<Icon name={danger ? 'phone_missed' : icon} className="text-base" />
{label}
</span>
</div>
);
}
/** Mounted once in AppShell. */
export function CallRoot() {
const { phase, minimized } = useCallStore();
if (phase === 'incoming')
return (
<>
<div className="hidden md:contents">
<IncomingCallCard />
</div>
<div className="contents md:hidden">
<MobileIncomingCall />
</div>
</>
);
if (phase === 'outgoing' || phase === 'connected') return minimized ? <MinimizedCallBar /> : <CallWindow />;
if (phase === 'ended') return <EndedCallOverlay />;
return null;
}
@@ -1,318 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { QRScannerModal } from '@/features/contacts/QRScannerModal';
import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api/friends';
import { submitGroupJoinRequest } from '@/lib/api/groups';
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
import { useEscapeKey } from '@/lib/hooks';
import { notImplemented } from '@/stores/globalToast';
const MAX_MESSAGE_LENGTH = 50;
interface AddFriendModalProps {
onClose: () => void;
onSent?: () => void;
/** FriendProfileView's "添加好友" button already knows exactly who -- skips straight past the
* search step to the verification-message step, rather than making the user re-search someone
* whose profile is already open. */
initialTarget?: { user_id: string; display_name: string; public_account?: string; avatar_file_id?: string };
}
/**
* README §5.4 (520px). 扫一扫/我的二维码 are real, confirmed-live features now (see
* lib/api/qrcodes.ts's header comment for the live-verification writeup) -- only "分享好友" stays
* the same "该功能暂未开放" placeholder GroupDetailColumn's own 投诉举报 button already uses, since
* sharing a friend's card into a conversation has no backend support anywhere. Search result rows
* drive their state from
* `relationship`/`can_add` (doc line 1423's confirmed response has no separate `action` field to
* read a label off of): already a friend or already pending are shown but not selectable, `can_add:
* false` shows a generic "无法添加" per the doc's own "不泄露具体关闭项" privacy principle, and only
* a genuinely addable result can be picked before writing the verification message.
*/
export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModalProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [searchedFor, setSearchedFor] = useState('');
const [selected, setSelected] = useState<UserSearchResult | null>(
initialTarget ? { ...initialTarget, relationship: 'none', can_add: true } : null,
);
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [sent, setSent] = useState(false);
const [showMyQrCode, setShowMyQrCode] = useState(false);
const [scanning, setScanning] = useState(false);
// A scanned `purpose:"join_group"` code -- a completely different target shape from `selected`
// (a person), so it gets its own state rather than being forced into UserSearchResult's fields.
const [groupJoinTarget, setGroupJoinTarget] = useState<(QRResolveGroup & { qrToken: string }) | null>(null);
useEscapeKey(onClose);
const handleScanResolved = (user: QRResolveUser) => {
setScanning(false);
setSelectedSource('qr');
setSelected({ user_id: user.user_id, display_name: user.display_name, public_account: user.public_account, relationship: 'none', can_add: true });
};
const handleScanAlreadyFriend = (user: QRResolveUser) => {
setScanning(false);
onClose();
navigate(`/contacts/${user.user_id}`);
};
const handleScanGroupResolved = (group: QRResolveGroup, qrToken: string) => {
setScanning(false);
setGroupJoinTarget({ ...group, qrToken });
};
const handleScanAlreadyMember = (group: QRResolveGroup) => {
setScanning(false);
onClose();
navigate(`/messages/${group.topic}`);
};
// docs/IM-前后端对接文档.md:1308: phone-number search only actually happens under `type=phone` --
// the default `type=account` never matches a bare phone number, so a search box that always sends
// `account` can never satisfy README §5.4's own placeholder implying phone-number search works.
const searchType = /^\d{11}$/.test(searchedFor) ? 'phone' : 'account';
const searchQuery = useQuery({
queryKey: ['user-search', searchedFor, searchType],
queryFn: () => searchUsers(searchedFor, searchType),
enabled: !!searchedFor,
});
const runSearch = () => {
setSelected(null);
setSearchedFor(query.trim());
};
const sendMutation = useMutation({
mutationFn: () => sendFriendRequest(selected?.user_id || '', message.trim() || undefined, selectedSource),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
setSent(true);
onSent?.();
},
onError: (err) => setError(err instanceof Error ? err.message : '发送申请失败'),
});
const groupJoinMutation = useMutation({
mutationFn: () =>
submitGroupJoinRequest(groupJoinTarget?.topic || '', { message: message.trim() || undefined, source: 'qr', qr_token: groupJoinTarget?.qrToken }),
onSuccess: () => setSent(true),
onError: (err) => setError(err instanceof Error ? err.message : '发送入群申请失败'),
});
const rowState = (item: UserSearchResult): { label: string; selectable: boolean } => {
if (item.relationship === 'friend') return { label: '已是好友', selectable: false };
// docs/IM-前后端对接文档.md:1289 -- the real enum is direction-specific, never a bare "pending"
// (comparing against that literal never matched either real value, so a search result with an
// outstanding request either direction fell through to the `can_add` check below and could look
// fully selectable, risking a duplicate friend-requests submit).
if (item.relationship === 'pending_outbound') return { label: '已发送申请', selectable: false };
if (item.relationship === 'pending_inbound') return { label: '对方已申请加你', selectable: false };
if (!item.can_add) return { label: '无法添加', selectable: false };
return { label: '', selectable: true };
};
return (
<>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex max-h-[80vh] w-[520px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">添加好友</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
{sent ? (
<div className="flex flex-col items-center gap-3 px-5 py-16">
<Icon name="check_circle" className="text-[40px] text-success" />
<span className="text-sm text-text-body">申请已发送</span>
<button type="button" onClick={onClose} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
完成
</button>
</div>
) : (
<div className="flex-1 overflow-y-auto px-5 py-4">
{groupJoinTarget ? (
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg bg-accent text-sm font-medium text-white">
{groupJoinTarget.name.slice(0, 1)}
</span>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{groupJoinTarget.name}</strong>
<span className="truncate text-xs text-text-tertiary-2">{groupJoinTarget.member_count} 人</span>
</span>
</div>
) : initialTarget ? (
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
<Avatar
id={initialTarget.user_id}
name={initialTarget.display_name}
avatarFileId={initialTarget.avatar_file_id}
className="h-10 w-10 flex-none rounded-lg text-sm"
/>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{initialTarget.display_name}</strong>
{initialTarget.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {initialTarget.public_account}</span> : null}
</span>
</div>
) : (
<>
<div className="flex items-center gap-2">
<div className="flex h-10 flex-1 items-center rounded-lg border border-[#d5d9df] px-3 focus-within:border-accent focus-within:shadow-[0_0_0_3px_rgba(22,119,255,0.1)]">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && runSearch()}
placeholder="手机号/账号/昵称"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
<button type="button" onClick={runSearch} disabled={!query.trim()} className="flex-none text-sm text-accent disabled:opacity-40">
搜索
</button>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) },
{ icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) },
{ icon: 'share', label: '分享好友', onClick: notImplemented },
].map((action) => (
<button
key={action.label}
type="button"
onClick={action.onClick}
className="flex items-center justify-center gap-2 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
>
<Icon name={action.icon} className="text-xl text-accent" />
{action.label}
</button>
))}
</div>
</>
)}
{!initialTarget && !groupJoinTarget && searchedFor ? (
<div className="mt-4 flex flex-col gap-2">
{searchQuery.isLoading ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchQuery.data?.items.length ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
) : (
searchQuery.data.items.map((item) => {
const { label, selectable } = rowState(item);
const isSelected = selected?.user_id === item.user_id;
return (
<button
key={item.user_id}
type="button"
disabled={!selectable}
onClick={() => {
// Real, live-confirmed backend validation (im_http.go's
// serveIMFriendRequestCreate): the `source` field's switch only recognizes
// `search`/`qr`/`group` -- there is no `phone` case, so it falls to
// `default` and 400s with "invalid friend request source". A prior pass
// here tried to report phone-originated searches as their own `phone`
// source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"),
// which broke every phone-search-based friend request outright -- reproduced
// live with two brand-new accounts before this fix (first-ever request,
// never a retry/idempotency issue as it first looked). Phone and account
// search both go through the same `search` channel from the backend's own
// point of view; there is no server-side data to recover that distinction
// from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this.
setSelectedSource('search');
setSelected(item);
}}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
>
<Avatar id={item.user_id} name={item.display_name} avatarFileId={item.avatar_file_id} className="h-10 w-10 flex-none rounded-lg text-sm" />
<span className="flex min-w-0 flex-1 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{item.display_name}</strong>
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
</span>
{selectable ? (
<span className={`flex-none rounded-md px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
{isSelected ? '已选择' : '选择'}
</span>
) : (
<span className="flex-none text-xs text-text-tertiary-3">{label}</span>
)}
</button>
);
})
)}
</div>
) : null}
{selected || groupJoinTarget ? (
<div className="mt-4">
<div className="mb-1 text-sm text-text-body">{groupJoinTarget ? '申请留言' : '验证留言'}</div>
<div className="relative">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
rows={3}
placeholder={groupJoinTarget ? '申请入群' : `我是${selected?.display_name}的朋友`}
className="w-full resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
/>
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
{message.length}/{MAX_MESSAGE_LENGTH}
</span>
</div>
</div>
) : null}
</div>
)}
{sent ? null : (
<footer className="flex flex-none items-center justify-end gap-2 border-t border-border-list-item px-5 py-3">
<button type="button" onClick={onClose} className="h-9 rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary">
取消
</button>
<button
type="button"
onClick={() => (groupJoinTarget ? groupJoinMutation.mutate() : sendMutation.mutate())}
disabled={(!selected && !groupJoinTarget) || sendMutation.isPending || groupJoinMutation.isPending}
className="h-9 rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:bg-btn-disabled"
>
{sendMutation.isPending || groupJoinMutation.isPending ? '发送中…' : groupJoinTarget ? '申请加入' : '发送申请'}
</button>
</footer>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
{/* Siblings of the backdrop above, not nested inside it -- their own backdrop-click-to-close
would otherwise bubble up and close THIS modal too, since only the 520px card (not the
backdrop) stops propagation. */}
{showMyQrCode ? <QRCodeModal onClose={() => setShowMyQrCode(false)} /> : null}
{scanning ? (
<QRScannerModal
onClose={() => setScanning(false)}
onResolved={handleScanResolved}
onAlreadyFriend={handleScanAlreadyFriend}
onGroupResolved={handleScanGroupResolved}
onAlreadyMember={handleScanAlreadyMember}
/>
) : null}
</>
);
}
-158
View File
@@ -1,158 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
import { formatEventTimestamp } from '@/lib/messages/time';
/** README §5.6. `getBlocks` nests the user under `.user` (see friends.ts's own comment on that
* endpoint's real shape, a doc-drift bug caught during Phase 1-2's review).
*
* Multi-select + batch-unblock has no dedicated mock anywhere in this design handoff (04-contacts.dc.html's
* own #1g only ever shows a single row, one 移出 button each) -- rather than invent a new interaction
* pattern from nothing, this reuses the SAME 多选/已选择N项/取消 header convention FavoritesLayout.tsx
* already established for its own multi-select, so it's consistent with something this app already does,
* not a one-off design.
*/
export function BlocklistView() {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const unblockMutation = useMutation({
mutationFn: (userId: string) => unblockUser(userId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => setError(err instanceof Error ? err.message : '移出黑名单失败'),
});
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const bulkUnblockMutation = useMutation({
mutationFn: (userIds: string[]) => batchUnblockUsers(userIds),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
// A 200 here only means the REQUEST was accepted -- the doc's own worked example returns a
// per-item `failed[]` array alongside `items[]`, so some of the selected users can fail to
// unblock while the rest succeed. Silently treating this as full success (the same pattern
// already fixed for favorites/GroupAdminModal elsewhere in this app) would leave a user still
// on the blocklist with no indication anything went wrong.
if (data.failed?.length) setError(`${data.failed.length} 人移出失败,请重试`);
exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '批量移出失败'),
});
const toggleSelected = (userId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
{selectMode ? (
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="text-sm text-text-secondary">
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => bulkUnblockMutation.mutate(Array.from(selected))}
disabled={selected.size === 0 || bulkUnblockMutation.isPending}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
>
<Icon name="remove_circle_outline" className="text-[18px]" />
移出
</button>
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
取消
</button>
</span>
</header>
) : (
<header className="flex h-15 flex-none items-center gap-1 border-b border-border-panel bg-white px-6">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="flex-1 text-base font-semibold text-text-heading">
黑名单{items.length ? ` ${items.length}` : ''}
</strong>
{items.length > 1 ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : null}
</header>
)}
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
) : (
items.map((b) => (
<div
key={b.user.user_id}
role={selectMode ? 'button' : undefined}
tabIndex={selectMode ? 0 : undefined}
onClick={selectMode ? () => toggleSelected(b.user.user_id) : undefined}
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
>
{selectMode ? <SelectionCheckbox selected={selected.has(b.user.user_id)} /> : null}
<Avatar
id={b.user.user_id}
name={b.user.display_name}
avatarFileId={b.user.avatar_file_id}
className="h-11 w-11 flex-none rounded-lg text-base grayscale"
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{b.user.display_name || b.user.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
拉黑于 {formatEventTimestamp(new Date(b.blocked_at))} · 对方无法向你发送消息或发起通话
</span>
</span>
{selectMode ? null : (
<button
type="button"
onClick={() => unblockMutation.mutate(b.user.user_id)}
disabled={unblockMutation.isPending}
className="h-8 flex-none rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
移出
</button>
)}
</div>
))
)}
{items.length ? (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">移出黑名单后恢复通信权限,但不会自动恢复已删除的好友关系。</p>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -1,29 +0,0 @@
import { Outlet, useLocation } from 'react-router-dom';
import { ContactsListColumn } from '@/features/contacts/ContactsListColumn';
/**
* README §5.1's 3-column split (NavRail is AppShell's own, rendered above this): list column always
* docked (unlike messages, contacts has no detail-panel breakpoint to collapse), main area is
* whatever `/contacts/*` sub-route is active. Mirrors MessagesLayout's ChatListColumn+Outlet shape,
* minus the detail-panel/history-modal machinery messages needs and contacts doesn't.
*
* `showListOnNarrow` can't key off a route param the way MessagesLayout keys off `topicId` --
* `/contacts/requests|groups|blocklist` are sibling routes with no param at all, not variants of
* `:userId`, so this checks the exact index path instead.
*/
export function ContactsLayout() {
const location = useLocation();
const showListOnNarrow = location.pathname === '/contacts';
return (
<div className="flex h-full w-full">
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
<ContactsListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet />
</div>
</div>
);
}
@@ -1,180 +0,0 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
const FIXED_ENTRIES = [
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
// §5.1's desktop entry list doesn't include it.
{ key: 'system', route: '/messages/system', icon: 'notifications', label: '系统消息', colorClass: 'bg-warn-bg text-warn', mobileOnly: true },
] as const;
/** Server-computed `initial` (doc line 1417) sorts CJK names correctly without a client-side pinyin
* library; falls back to the display name's own first character for the rare item missing one. */
function groupInitial(name: string, initial: string | undefined): string {
const raw = (initial || name || '').trim().charAt(0).toUpperCase();
return /[A-Z]/.test(raw) ? raw : '#';
}
/**
* README §5.1 (320px): search + fixed entries (新朋友/群聊/黑名单, 文件助手 removed -- see
* ChatListColumn.tsx's PINNED_ENTRIES doc comment for why) + letter-grouped
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
* grouped and searched client-side, same convention ChatListColumn already uses for conversations
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
*/
export function ContactsListColumn() {
const { userId } = useParams();
const [query, setQuery] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
// Unlike friends/blocks (both already fetchAllPages), this used to read only page 1 -- the request
// COUNT BADGE would silently undercount for any account with more than one page of pending requests.
const requestsQuery = useQuery({
queryKey: ['friend-requests', 'inbound', 'pending'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'pending', cursor)),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const trimmedQuery = query.trim().toLowerCase();
const friends = friendsQuery.data || [];
const filtered = trimmedQuery ? friends.filter((f) => (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmedQuery)) : friends;
const groups = useMemo(() => {
const byLetter = new Map<string, typeof filtered>();
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || '').localeCompare(b.remark || b.display_name || '', 'zh'))) {
const letter = groupInitial(f.remark || f.display_name || f.user_id, f.initial);
byLetter.set(letter, [...(byLetter.get(letter) || []), f]);
}
return [...byLetter.entries()].sort(([a], [b]) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b)));
}, [filtered]);
const pendingRequestCount = requestsQuery.data?.length ?? 0;
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-10 w-10 place-items-center text-text-heading md:h-9 md:w-9">
<Icon name="person_add_alt" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索联系人"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="relative flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto">
{trimmedQuery ? null : (
<>
{FIXED_ENTRIES.map((entry) => {
const badge =
entry.key === 'requests'
? pendingRequestCount
: entry.key === 'blocklist'
? (blocksQuery.data?.length ?? 0)
: 0;
return (
<Link
key={entry.key}
to={entry.route}
className={`flex h-13 flex-none items-center gap-3 border-b border-border-list-item px-4 no-underline hover:bg-[#f6f8fb] ${
'mobileOnly' in entry && entry.mobileOnly ? 'md:hidden' : ''
}`}
>
{/* README §5.7: mobile fixed-entry icons/text are one size up from desktop's
36px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg md:h-9 md:w-9 ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-xl" />
</span>
<strong className="flex-1 truncate text-base font-medium text-[#272b31] md:text-[15px]">{entry.label}</strong>
{entry.key === 'requests' && badge ? (
<b className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{badge}
</b>
) : entry.key === 'groups' ? (
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
) : entry.key === 'blocklist' && badge ? (
<span className="flex-none text-xs text-text-tertiary-3">{badge}</span>
) : null}
</Link>
);
})}
</>
)}
{friendsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : friendsQuery.isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => friendsQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
<span className="text-sm">{trimmedQuery ? '未找到相关联系人' : '暂无联系人'}</span>
</div>
) : (
groups.map(([letter, items]) => (
<div key={letter} id={`contact-letter-${letter}`}>
<div className="bg-[#fafbfc] px-4 pb-1 pt-3.5 text-xs text-text-tertiary-2">{letter}</div>
{items.map((f) => {
const name = f.remark || f.display_name || f.user_id;
const active = userId === f.user_id;
return (
<Link
key={f.user_id}
to={`/contacts/${f.user_id}`}
className={`flex h-13.5 flex-none items-center gap-3 px-4 no-underline ${active ? 'bg-accent-selected-bg' : 'hover:bg-[#f6f8fb]'}`}
>
{/* README §5.7: mobile friend-row avatar/text are 44px/16px, one size up from
desktop's 40px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<Avatar id={f.user_id} name={name} avatarFileId={f.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base md:h-10 md:w-10 md:text-sm" />
<span className="truncate text-base text-[#272b31] md:text-[15px]">{name}</span>
</Link>
);
})}
</div>
))
)}
</div>
{trimmedQuery || groups.length === 0 ? null : (
<div className="flex w-4 flex-none flex-col items-center justify-center gap-0.5 py-2">
{groups.map(([letter]) => (
<a
key={letter}
href={`#contact-letter-${letter}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(`contact-letter-${letter}`)?.scrollIntoView({ block: 'start' });
}}
className="text-[10px] leading-[14px] text-text-tertiary-2 hover:font-semibold hover:text-accent"
>
{letter}
</a>
))}
</div>
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
</aside>
);
}
@@ -1,10 +0,0 @@
import { Icon } from '@/app/Icon';
export function EmptyContactsMain() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
<Icon name="person_search" className="!text-5xl" />
<span className="text-sm">选择一位联系人查看资料</span>
</div>
);
}
@@ -1,265 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { ImApiError } from '@/lib/api/client';
import { getUserProfile, updateFriendRemark } from '@/lib/api/friends';
import { useCallStore } from '@/stores/calls';
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
/**
* README §5.2 (440px centered card). `getUserProfile` (doc line 1328/1425) has no field describing
* HOW two people became friends -- the design's "来源:通过群聊「项目协作群」添加" row has nothing
* to read it off of, so it's left out rather than fabricated; every other row maps to a real field.
*/
export function FriendProfileView() {
const { userId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [editingRemark, setEditingRemark] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const [error, setError] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const [showQrCode, setShowQrCode] = useState(false);
const query = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => getUserProfile(userId || ''),
enabled: !!userId,
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(userId || '', remark),
onSuccess: (_data, remark) => {
queryClient.setQueryData(['user-profile', userId], (prev: typeof query.data) => (prev ? { ...prev, remark } : prev));
queryClient.invalidateQueries({ queryKey: ['friends'] });
setEditingRemark(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '备注保存失败'),
});
// `/contacts/:userId` is a route only reachable via a route change (list item, search result),
// never a param variant of the index -- so ContactsLayout's showListOnNarrow hides the list here
// just like every other narrow-viewport sub-route, and this page needs the same back affordance
// GroupListView/NewFriendsView/BlocklistView have in their own header. This one has no header bar
// at any state (loading/not-found/loaded), so the link is repeated across all three returns.
const backLink = (
<Link to="/contacts" className="absolute left-4 top-4 z-10 grid h-9.5 w-9.5 place-items-center rounded-full bg-white/70 text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
);
if (!userId) return null;
if (query.isLoading) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{backLink}
加载中…
</div>
);
}
const profile = query.data;
if (!profile) {
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
// as if the user doesn't exist.
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
// A genuine 404 is the only case that should actually read as "this user doesn't exist" --
// every OTHER failure (network error, 5xx, a transient timeout) used to fall into that same
// "未找到该用户" bucket too, telling the user an account is gone/never existed when the real
// problem is just that this one request failed and could succeed on retry.
const isNotFound = query.error instanceof ImApiError && query.error.status === 404;
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-sm text-text-tertiary-4">
{backLink}
{isPrivate ? (
'对方设置了资料保护,暂时无法查看'
) : isNotFound ? (
'未找到该用户'
) : (
<>
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</>
)}
</div>
);
}
const isFriend = profile.relationship === 'friend';
// docs/IM-前后端对接文档.md:1289's real enum is direction-specific -- `isFriend` alone used to
// collapse BOTH pending states into "not a friend yet", which fell through to the generic "添加
// 好友" button below and let the user re-open AddFriendModal and resubmit a request that's already
// outstanding either direction.
const isPendingOutbound = profile.relationship === 'pending_outbound';
const isPendingInbound = profile.relationship === 'pending_inbound';
const name = profile.remark || profile.display_name;
const metaParts = [profile.display_name ? `昵称:${profile.display_name}` : null, profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region]
.filter(Boolean)
.join(' · ');
// README §5.2 (desktop, 44px) vs §5.7 (mobile, 48px) want the SAME three actions at two different
// sizes in two different places (inline in the card vs a fixed bottom bar, see below) -- one
// function instead of duplicating the branching/handlers so there's exactly one behavior to get
// right, not two copies that can drift.
const actionButtons = (squareBtnClass: string, iconSizeClass: string) =>
isFriend ? (
<>
<button
type="button"
// `profile.user_id`, not `profile.topic` -- confirmed live (see favorites.ts's own
// finding) that this API's `topic` field for a P2P relationship is the server's
// internal `p2p...`-hashed form, which this app's chat list/router never uses;
// every P2P conversation elsewhere in this app is addressed by the peer's own
// `usr...` id, which Tinode resolves to the same real topic on the wire.
onClick={() => navigate(`/messages/${profile.user_id}`)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
>
<Icon name="chat_bubble" className={iconSizeClass} />
发消息
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'audio');
}}
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
>
<Icon name="call" />
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'video');
}}
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
>
<Icon name="videocam" />
</button>
</>
) : isPendingOutbound ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
等待验证
</button>
) : isPendingInbound ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
对方已申请加你
</button>
) : profile.capabilities?.can_add_friend === false ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
对方暂不可添加
</button>
) : (
<button
type="button"
onClick={() => setAddingFriend(true)}
className={`flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
>
添加好友
</button>
);
return (
<div className="relative flex h-full w-full items-center-safe justify-center overflow-y-auto bg-surface-content md:py-12">
{backLink}
<div className="w-full flex-none overflow-hidden bg-white shadow-[0_8px_32px_rgba(35,74,125,0.08)] md:w-[440px] md:rounded-2xl">
<div className="h-24 bg-gradient-to-br from-[#e8f1fe] to-[#f3efff]" />
<div className="flex flex-col items-center px-6 pb-24 md:pb-6">
<Avatar
id={profile.user_id}
name={name}
avatarFileId={profile.avatar_file_id}
className="-mt-11 h-22 w-22 flex-none rounded-[20px] border-4 border-white text-2xl"
/>
<strong className="mt-3 text-xl font-semibold text-text-heading">{name}</strong>
{metaParts ? <span className="mt-1 text-[13px] text-text-tertiary-2">{metaParts}</span> : null}
{profile.public_account ? <span className="text-[13px] text-text-tertiary-2">ID: {profile.public_account}</span> : null}
{profile.signature ? <p className="m-0 mt-2 text-sm text-text-secondary">{profile.signature}</p> : null}
{/* README §5.7: mobile pins this row OUTSIDE the scroll area (bottom-fixed), so it stays
reachable regardless of profile length -- this hidden copy keeps the original inline
desktop placement exactly as before; the real mobile copy is the fixed bar rendered
after the card below. Both call the same handlers, so there is exactly one behavior. */}
<div className="mt-5 hidden w-full items-center gap-2 md:flex">{actionButtons('h-11 w-11', 'text-[18px]')}</div>
{isFriend ? (
<div className="mt-4 flex w-full flex-col divide-y divide-border-list-item border-t border-border-list-item text-sm">
{editingRemark ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
<input
autoFocus
value={remarkDraft}
onChange={(e) => setRemarkDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft.trim())}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft.trim())} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setRemarkDraft(profile.remark || '');
setEditingRemark(true);
}}
className="flex justify-between py-3 text-left"
>
<span className="text-text-body">备注</span>
<span className="flex items-center gap-0.5 text-text-tertiary-2">
{profile.remark || '未设置'}
<Icon name="chevron_right" className="text-[18px]" />
</span>
</button>
)}
<button type="button" onClick={() => setShowQrCode(true)} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">二维码名片</span>
<Icon name="qr_code_2" className="text-[20px] text-text-tertiary-2" />
</button>
</div>
) : null}
</div>
</div>
{/* Mobile-only fixed bottom bar (mock `04/1e`: `padding:12px 16px 34px`, 48px buttons) -- the
real mobile counterpart to the hidden desktop copy above, pinned to the viewport bottom
(not the card) so it stays reachable no matter how far the profile content scrolls.
`bottom-[82px] sm:bottom-0`: `position:fixed` resolves against the viewport, not this
route's own Outlet box, and below 640px the viewport bottom is where AppShell's persistent
BottomTabBar (82px) already lives -- an unqualified `bottom-0` sat directly on top of it,
hiding the tab bar entirely while this page was open. `sm:bottom-0` restores flush-bottom
once the tab bar is gone (>=640px, NavRail shown on the side instead). */}
<div className="fixed inset-x-0 bottom-[82px] z-10 flex gap-3 bg-white px-4 pb-[34px] pt-3 sm:bottom-0 md:hidden">
{actionButtons('h-12 w-12', 'text-xl')}
</div>
{showQrCode && userId ? <QRCodeModal identifier={userId} onClose={() => setShowQrCode(false)} /> : null}
{addingFriend ? (
<AddFriendModal
onClose={() => setAddingFriend(false)}
initialTarget={{
user_id: profile.user_id,
display_name: profile.display_name,
public_account: profile.public_account,
avatar_file_id: profile.avatar_file_id,
}}
/>
) : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
-122
View File
@@ -1,122 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups, type GroupRole } from '@/lib/api/groups';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
type Tab = 'all' | 'owner' | 'admin' | 'member';
const TABS: { key: Tab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'owner', label: '我创建的' },
{ key: 'admin', label: '我管理的' },
{ key: 'member', label: '我加入的' },
];
/** README §5.5. Fetched once (category='', i.e. every group) and tabbed client-side by `my_role`
* rather than hitting `getGroups`'s own owned/managed/joined category filters three more times --
* same total data, one request instead of four. */
export function GroupListView() {
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('all');
const [creatingGroup, setCreatingGroup] = useState(false);
// Only page 1 used to be fetched -- an account with more than one page of groups would silently
// undercount every tab (including "全部") and never list the rest.
const query = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const groups = query.data || [];
const counts: Record<Tab, number> = {
all: groups.length,
owner: groups.filter((g) => g.my_role === 'owner').length,
admin: groups.filter((g) => g.my_role === 'admin').length,
member: groups.filter((g) => g.my_role === 'member').length,
};
const filtered = tab === 'all' ? groups : groups.filter((g) => g.my_role === tab);
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="flex items-center gap-1">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">群聊</strong>
</span>
<button type="button" onClick={() => setCreatingGroup(true)} className="flex items-center gap-1 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
创建群聊
</button>
</header>
<div className="flex flex-none items-center gap-6 border-b border-border-panel bg-white px-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`h-11 border-b-2 text-sm ${tab === t.key ? 'border-accent font-medium text-accent' : 'border-transparent text-text-secondary'}`}
>
{t.label} {counts[t.key]}
</button>
))}
</div>
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
) : (
filtered.map((g) => (
<button
key={g.topic}
type="button"
onClick={() => navigate(`/messages/${g.topic}`)}
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 text-left shadow-bubble"
>
<Avatar id={g.topic} name={g.name} avatarFileId={g.avatar_file_id} className="h-11 w-11 flex-none rounded-[10px] text-base" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-text-body">{g.name}</strong>
{g.my_role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${g.my_role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'}`}
>
{ROLE_LABEL[g.my_role]}
</span>
) : null}
</span>
<span className="truncate text-xs text-text-tertiary-2">
{/* `GET /groups`'s own `member_count` can come back as a bare 0 for a real, non-empty
group (confirmed live -- same backend dual-counter split as ChatListColumn's own
"N人" pill, 问题8 in the backend issue list) -- no real group has zero members, so
an exact zero here means "don't trust this field", not "empty group". */}
{g.member_count > 0 ? `${g.member_count} 人 · ` : null}群号 {g.topic}
</span>
</span>
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
</button>
))
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</div>
);
}
@@ -1,134 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type FriendRequest } from '@/lib/api/friends';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
// `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's
// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the
// same `search` value an account-name search does, since the server has no separate channel for it
// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it
// tried to report `phone` as its own source instead).
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
* that, not a pending-only fetch. */
export function NewFriendsView() {
const queryClient = useQueryClient();
const [addingFriend, setAddingFriend] = useState(false);
const [error, setError] = useState('');
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
// Only page 1 used to be fetched -- an account with more than one page of historical requests
// would silently never show its older ones.
const query = useQuery({
queryKey: ['friend-requests', 'all'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'all', cursor)),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
queryClient.invalidateQueries({ queryKey: ['friends'] });
};
const acceptMutation = useMutation({
mutationFn: (req: FriendRequest) => acceptFriendRequest(req.request_id),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
invalidate();
},
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
});
const rejectMutation = useMutation({
mutationFn: (req: FriendRequest) => rejectFriendRequest(req.request_id),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
invalidate();
},
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="flex items-center gap-1">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">新朋友</strong>
</span>
<button type="button" onClick={() => setAddingFriend(true)} className="text-sm text-accent">
添加好友
</button>
</header>
<div className="flex flex-col gap-3 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
) : (
items.map((req) => {
const status = handled[req.request_id] || (req.status !== 'pending' ? (req.status === 'accepted' ? 'accepted' : 'rejected') : null);
const name = req.peer?.display_name || req.peer?.user_id || req.applicant_id;
return (
<div key={req.request_id} className="flex items-center gap-3 rounded-xl bg-white px-4.5 py-3.5 shadow-bubble">
<span className={`grid h-12 w-12 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.applicant_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
{req.message ? <span className="truncate text-[13px] text-text-secondary">{req.message}</span> : null}
<span className="truncate text-xs text-text-tertiary-3">
{formatEventTimestamp(new Date(req.created_at))}
{req.source ? ` · ${SOURCE_LABEL[req.source] || req.source}` : ''}
</span>
</span>
{status ? (
<span className="flex-none text-xs text-text-tertiary-3">{status === 'accepted' ? '已通过' : '已拒绝'}</span>
) : (
<span className="flex flex-none items-center gap-2">
<button
type="button"
onClick={() => rejectMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
拒绝
</button>
<button
type="button"
onClick={() => acceptMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
通过验证
</button>
</span>
)}
</div>
);
})
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
-165
View File
@@ -1,165 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
interface QRCodeModalProps {
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
* (README's "二维码名片" row on a friend's profile). */
identifier?: string;
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
groupTopic?: string;
onClose: () => void;
}
/**
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
*/
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
useEscapeKey(onClose);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawError, setDrawError] = useState('');
const query = useQuery<MyQRCode | GroupQRCode>({
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
});
const draw = () => {
if (!query.data || !canvasRef.current) return;
const canvas = canvasRef.current;
setDrawError('');
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
// isn't in every visitor's main bundle when most sessions never open this modal.
import('qrcode')
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
.catch(() => setDrawError('二维码生成失败'));
};
useEffect(draw, [query.data]);
const retry = () => {
if (query.isError) query.refetch();
else draw();
};
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
const saveToAlbum = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${fileBaseName}.png`;
link.click();
URL.revokeObjectURL(url);
};
const shareToFriend = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
// `canShare({ files })` is the one call that actually answers "can this share an image".
if (navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: fileBaseName });
} catch {
// AbortError (user cancelled the native share sheet) or any other failure -- either way
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
}
} else {
await saveToAlbum();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex flex-col items-center gap-3 px-6 py-7">
{profile ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
{avatarLetter(profile.display_name)}
</span>
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
</>
) : group ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
{avatarLetter(group.name)}
</span>
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
</>
) : null}
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
{query.isLoading ? (
<span className="text-sm text-text-tertiary-4">生成中…</span>
) : query.isError || drawError ? (
<div className="flex flex-col items-center gap-2 px-4 text-center">
<Icon name="error_outline" className="text-2xl text-danger-text" />
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
<button type="button" onClick={retry} className="text-xs text-accent">
重试
</button>
</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
<span className="text-xs text-text-tertiary-3">{caption}</span>
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
{query.data && !drawError ? (
<div className="mt-1 flex gap-2">
<button
type="button"
onClick={saveToAlbum}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="download" className="text-[16px]" />
保存到相册
</button>
<button
type="button"
onClick={shareToFriend}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="share" className="text-[16px]" />
分享好友
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -1,229 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { resolveQRCode, type QRResolveGroup, type QRResolveUser } from '@/lib/api/qrcodes';
import { useEscapeKey } from '@/lib/hooks';
interface QRScannerModalProps {
onClose: () => void;
/** A resolved, genuinely-addable ('none' relationship, can_add) scanned user. */
onResolved: (user: QRResolveUser) => void;
/** Scanned code belongs to someone already a friend -- caller decides what "already added"
* should do (e.g. navigate to their profile) instead of re-showing an add-friend form. */
onAlreadyFriend: (user: QRResolveUser) => void;
/** A `purpose:"join_group"` code resolved successfully and the scanning account is NOT already a
* member -- optional so a caller that only ever scans for the add-friend flow (there is none
* currently, but the type shouldn't force every caller to handle a case it can't reach) doesn't
* have to wire a no-op. `qrToken` is threaded through because the join-request submission itself
* needs to carry it (docs/IM-前后端对接文档.md:1591). */
onGroupResolved?: (group: QRResolveGroup, qrToken: string) => void;
/** Mirrors `onAlreadyFriend` for the group case -- confirmed live that resolving a code for a
* group you're already in comes back `status:"joined"` rather than something requiring a join
* request at all (see qrcodes.ts's QRResolveResult comment). */
onAlreadyMember?: (group: QRResolveGroup) => void;
}
const SCAN_INTERVAL_MS = 250;
/** Old app's own real, working scanner (im_webapp/src/widgets/qr-scanner.jsx) downscales to this
* max edge before decoding -- jsQR's cost scales with pixel count, and a raw 1080p+ camera frame
* decoded every 250ms would be needlessly expensive when a QR code only needs a few hundred px
* per side to read reliably. */
const MAX_FRAME_EDGE = 480;
type ScanState = 'requesting' | 'scanning' | 'resolving' | 'camera-error' | 'resolve-error';
/**
* README §5.4's "扫一扫" -- full-screen camera viewfinder. Ported cognition (not code) from the old
* app's real, working `qr-scanner.jsx` (getUserMedia -> draw each frame to an offscreen canvas ->
* jsQR) and `tinode-web.jsx`'s `handleQrDetected` (reject anything not matching the app's own
* `im://qr?token=...&purpose=...` scheme client-side before ever sending it to `resolve`, so a
* generic URL/WiFi/whatever QR code someone points this at is never even round-tripped).
*/
export function QRScannerModal({ onClose, onResolved, onAlreadyFriend, onGroupResolved, onAlreadyMember }: QRScannerModalProps) {
useEscapeKey(onClose);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const resolvingRef = useRef(false);
const mountedRef = useRef(true);
const [state, setState] = useState<ScanState>('requesting');
const [errorMessage, setErrorMessage] = useState('');
const stopCamera = () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const handleDetected = async (text: string) => {
if (resolvingRef.current) return;
// Only this app's own generated codes resolve to anything -- a generic URL/WiFi/contact QR is
// rejected client-side rather than round-tripped to the server for a guaranteed-useless lookup.
if (!text.startsWith('im://qr?')) return;
const params = new URLSearchParams(text.slice('im://qr?'.length));
const token = params.get('token');
const purpose = params.get('purpose') || 'add_friend';
if (!token) return;
resolvingRef.current = true;
setState('resolving');
try {
const result = await resolveQRCode(token, purpose);
// The modal may have been closed (or the user's intent otherwise moved on) while this request
// was in flight -- applying its result now would redirect/populate state the user never asked
// for after they believed they'd already cancelled.
if (!mountedRef.current) return;
if (purpose === 'join_group') {
// See qrcodes.ts's own QRResolveGroup comment -- confirmed live this nests under `group`
// (group_snapshot checked too as a defensive fallback, not because it's expected to fire).
const group = result.group || result.group_snapshot;
if (!group || !onGroupResolved) {
// Same leak as the two other resolve-error branches below: without this, the scan loop and
// camera stream both kept running underneath the full-screen error card, so the SAME
// unsupported code re-triggered `resolveQRCode` every ~250ms indefinitely, and clicking
// "重试" then started a second stream/interval on top without ever stopping this one.
stopCamera();
setErrorMessage('暂不支持此类二维码');
setState('resolve-error');
resolvingRef.current = false;
return;
}
stopCamera();
if (result.status === 'joined' && onAlreadyMember) onAlreadyMember(group);
else onGroupResolved(group, token);
return;
}
if (purpose !== 'add_friend' || !result.user) {
stopCamera();
setErrorMessage('暂不支持此类二维码');
setState('resolve-error');
resolvingRef.current = false;
return;
}
stopCamera();
if (result.user.relationship === 'friend') {
onAlreadyFriend(result.user);
} else if (!result.user.can_add) {
setErrorMessage('无法添加该用户为好友');
setState('resolve-error');
resolvingRef.current = false;
} else {
onResolved(result.user);
}
} catch (err) {
if (!mountedRef.current) return;
stopCamera();
setErrorMessage(err instanceof Error ? err.message : '二维码已失效');
setState('resolve-error');
resolvingRef.current = false;
}
};
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-decoding library isn't
// in every visitor's main bundle when most sessions never open the scanner.
const startScanLoop = async () => {
const canvas = canvasRef.current;
const video = videoRef.current;
if (!canvas || !video) return;
const { default: jsQR } = await import('jsqr');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
timerRef.current = window.setInterval(() => {
if (!ctx || video.readyState !== video.HAVE_ENOUGH_DATA || resolvingRef.current) return;
const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(video.videoWidth, video.videoHeight));
const w = Math.max(1, Math.round(video.videoWidth * scale));
const h = Math.max(1, Math.round(video.videoHeight * scale));
canvas.width = w;
canvas.height = h;
ctx.drawImage(video, 0, 0, w, h);
const frame = ctx.getImageData(0, 0, w, h);
const code = jsQR(frame.data, w, h, { inversionAttempts: 'dontInvert' });
if (code?.data) handleDetected(code.data);
}, SCAN_INTERVAL_MS);
};
// React 18 StrictMode (dev only) runs mount -> cleanup -> mount synchronously, which starts TWO
// overlapping getUserMedia() calls; without this generation token, whichever resolves last wins
// and clobbers the other's streamRef/timerRef, leaking the loser's MediaStream (never .stop()'d)
// and its setInterval. Matches the same-class guard already used in tinode/connection.ts/topics.ts.
const requestGenerationRef = useRef(0);
const requestCamera = async () => {
const myGeneration = ++requestGenerationRef.current;
setState('requesting');
setErrorMessage('');
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
if (myGeneration !== requestGenerationRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setState('scanning');
startScanLoop();
} catch (err) {
if (myGeneration !== requestGenerationRef.current) return;
setErrorMessage(err instanceof Error ? err.message : '无法访问摄像头');
setState('camera-error');
}
};
useEffect(() => {
requestCamera();
return stopCamera;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const retry = () => {
resolvingRef.current = false;
requestCamera();
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black">
<header className="flex h-13 flex-none items-center justify-between px-4 text-white">
<strong className="text-[15px] font-semibold">扫一扫</strong>
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center">
<Icon name="close" />
</button>
</header>
<div className="relative flex-1">
<video ref={videoRef} playsInline muted className="h-full w-full object-cover" />
<canvas ref={canvasRef} className="hidden" />
{state === 'scanning' ? (
<div className="absolute inset-0 grid place-items-center">
<div className="h-56 w-56 rounded-2xl border-2 border-white/80" />
</div>
) : null}
{state === 'requesting' || state === 'resolving' ? (
<div className="absolute inset-0 grid place-items-center bg-black/40 text-sm text-white">
{state === 'requesting' ? '正在请求摄像头权限…' : '正在识别…'}
</div>
) : null}
{state === 'camera-error' || state === 'resolve-error' ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/70 px-8 text-center text-white">
<Icon name={state === 'camera-error' ? 'videocam_off' : 'error_outline'} className="text-4xl" />
<span className="text-sm">{state === 'camera-error' ? `无法访问摄像头:${errorMessage}` : errorMessage}</span>
<button type="button" onClick={retry} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
重试
</button>
</div>
) : null}
</div>
<p className="flex-none py-4 text-center text-sm text-white/70">将二维码放入框内,即可自动扫描</p>
</div>
);
}
@@ -1,534 +0,0 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { Toast } from '@/app/Toast';
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
import {
batchDeleteFavorites,
deleteFavorite,
favoriteEntityData,
forwardFavorites,
getFavorites,
type Favorite,
type FavoriteType,
} from '@/lib/api/favorites';
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
{ key: 'all', label: '全部', icon: 'all_inbox' },
{ key: 'text', label: '文本', icon: 'notes' },
{ key: 'image', label: '图片', icon: 'image' },
{ key: 'video', label: '视频', icon: 'videocam' },
{ key: 'voice', label: '语音', icon: 'mic' },
{ key: 'file', label: '文件', icon: 'description' },
{ key: 'link', label: '链接', icon: 'link' },
];
/**
* README §6.1's footer "陈明远 · 项目协作群" names both the sender AND the conversation -- the
* confirmed real `source` shape (doc line 1933) only carries `{topic, seq}`, no sender field, so
* this can only resolve the CONVERSATION side (via the chat list's own contact cache, same lookup
* ChatListColumn/ConversationView already do) and shows just that, not a fabricated sender name.
*
* Confirmed live: a P2P favorite's `source.topic` comes back in the server's internal `p2p...`-
* hashed form (e.g. "p2pH5dvZ3ckWxq4XhSKX6MIow"), not the peer's `usr...` id the chat list and
* every route in this app actually key P2P conversations by -- the SDK's own `Topic.topicType()`
* doesn't even recognize a "p2p" prefix (its table only has usr/grp/new/nch/chn/sys/slf), so this
* is a REST-API-only naming quirk, disconnected from the WS/SDK convention. There's no client-side
* way to reverse that hash back to the peer's user id, so a chat-list lookup by topic can never
* match it -- showing the raw hash string would be confusing, so this falls back to a plain label
* for that case instead of guessing.
*/
function SourceLine({ source }: { source: Favorite['source'] }) {
const contacts = useChatList();
if (!source) return null;
const known = contacts.find((c) => c.topic === source.topic)?.public?.fn;
// Same reasoning as the p2p fallback below -- a group this account has never actually opened as a
// real conversation (e.g. joined via an admin action, never chatted in) has no entry in the local
// chat-list cache either, and showing the raw internal topic id (`grpXXXXXXXX`) to the user reads
// as a broken/leaked implementation detail, not a "no data" state -- fall back to a generic label.
const fallback = source.topic.startsWith('p2p') ? '单聊消息' : source.topic.startsWith('grp') ? '群聊' : source.topic;
return <span className="truncate">{known || fallback}</span>;
}
function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite; selectMode: boolean; selected: boolean; onToggle: () => void }) {
const removed = item.availability !== 'available';
const body = (() => {
if (removed) return <div className="text-sm text-text-tertiary-4">该内容已失效或被删除</div>;
switch (item.type) {
case 'text':
return (
<p className="m-0 line-clamp-[5] max-h-[88px] overflow-hidden text-sm leading-[1.6] text-text-body">
{item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '')}
</p>
);
case 'image':
case 'video': {
const data = favoriteEntityData(item.snapshot.content, item.type === 'video' ? 'VD' : 'IM');
// Video's own `val` is the actual video bytes (webm/mp4) -- not paintable as an <img> --
// the paintable poster frame is the separate inline `data.preview` (confirmed live: base64
// JPEG signature); image has no separate poster since the image itself IS the thumbnail.
const inlineVal = typeof (item.type === 'video' ? data?.preview : data?.val) === 'string' ? ((item.type === 'video' ? data?.preview : data?.val) as string) : undefined;
const inlineMime = item.type === 'video' ? 'image/jpeg' : typeof data?.mime === 'string' ? data.mime : undefined;
return (
<MediaPreview
inlineVal={inlineVal}
inlineMime={inlineMime}
fileId={item.snapshot.thumbnail_file_id || item.snapshot.file_id}
isVideo={item.type === 'video'}
/>
);
}
case 'voice': {
// Reuses the same real, working audio player chat bubbles use (AttachmentPreview.tsx) --
// this card used to be static (a bare play_circle icon with no click handler at all), so a
// favorited voice message could never actually be played from this view.
const data = favoriteEntityData(item.snapshot.content, 'AU');
return (
// Stops the play/pause click from also bubbling up to the card's own onClick -- in
// selectMode that outer handler toggles selection, and without this, tapping play would
// simultaneously start/stop audio AND select/deselect the card.
<div className="rounded-lg bg-surface-rail px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<AudioAttachment
data={{
val: typeof data?.val === 'string' ? data.val : undefined,
ref: typeof data?.ref === 'string' ? data.ref : item.snapshot.file_id ? `/v0/file/s/${item.snapshot.file_id}` : undefined,
mime: typeof data?.mime === 'string' ? data.mime : undefined,
duration: typeof data?.duration === 'number' ? data.duration : item.snapshot.duration_ms,
}}
/>
</div>
);
}
case 'file': {
const data = favoriteEntityData(item.snapshot.content, 'EX');
const name = (typeof data?.name === 'string' ? data.name : undefined) || item.snapshot.file?.name || item.snapshot.name || '';
const size = (typeof data?.size === 'number' ? data.size : undefined) ?? item.snapshot.file?.size ?? item.snapshot.size ?? 0;
const mime = (typeof data?.mime === 'string' ? data.mime : undefined) || item.snapshot.file?.mime || item.snapshot.mime;
const style = fileTypeStyle(name, mime);
return (
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
<span className="text-xs text-text-tertiary-2">{formatFileSize(size)}</span>
</span>
</div>
);
}
case 'link': {
// Unlike image/video/voice/file, a Drafty `LN` entity annotates a RANGE of the message's own
// text with a hyperlink (see drafty.tsx's own LN case) -- it has no separate title/thumbnail
// data of its own the way a standalone attachment entity does. So a "link" favorite's real
// snapshot is most likely just the SAME whole-message wrapper every other type turned out to
// be (Part D's finding), meaning its own top-level `text` field (identical source the 'text'
// case above already reads) is the one thing actually guaranteed to be there -- the flat
// `title`/`url`/`thumbnail_file_id` fields below have never been confirmed against real data
// and are kept only as an optional enhancement layer, not the sole source. Without this
// fallback, a real link favorite whose backend response doesn't happen to populate those flat
// fields rendered a card with a completely empty title line.
const text = item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '');
const title = item.snapshot.title || item.snapshot.url || text;
// Only show a second line when it says something the title line doesn't already -- a
// hostname alongside a real title/url is useful, but repeating `text` under itself when
// that's ALSO what the title line fell back to would just be the same line twice.
// `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from
// another client's own Drafty implementation or the backend's own extraction logic (docs/
// IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single
// malformed value here must not crash the whole render (there's no error boundary around this
// list short of react-router's own default one, which would replace the entire logged-in UI).
let subtitle = title === text ? '' : text;
if (item.snapshot.url) {
try {
subtitle = new URL(item.snapshot.url).hostname;
} catch {
// fall through to the plain-text fallback computed above
}
}
return (
<div className="flex items-start gap-3">
{item.snapshot.thumbnail_file_id ? (
<span className="h-14 w-14 flex-none overflow-hidden rounded-lg bg-surface-rail">
<MediaPreview fileId={item.snapshot.thumbnail_file_id} isVideo={false} square />
</span>
) : (
<span className="grid h-14 w-14 flex-none place-items-center rounded-lg bg-surface-rail">
<Icon name="link" className="text-[20px] text-text-tertiary-3" />
</span>
)}
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{title}</span>
{subtitle ? <span className="truncate text-xs text-text-tertiary-2">{subtitle}</span> : null}
</span>
</div>
);
}
default:
return null;
}
})();
// A plain <div> (not <button>) -- the voice card nests AudioAttachment's own real <button> inside
// this, and a <button> can't legally contain another <button> (React logs a hydration-nesting
// error for it, confirmed live). role/tabIndex/onKeyDown restore button semantics only while this
// is actually clickable (selectMode); outside selectMode it's inert, same as before.
return (
<div
role={selectMode ? 'button' : undefined}
tabIndex={selectMode ? 0 : undefined}
onClick={selectMode ? onToggle : undefined}
onKeyDown={
selectMode
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}
: undefined
}
className={`flex flex-col gap-3 rounded-xl border-2 bg-white p-4 text-left ${selected ? 'border-accent' : 'border-transparent'}`}
>
<div className="relative">
{body}
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
</div>
<div className="flex items-center justify-between border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">
<SourceLine source={item.source} />
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
</div>
</div>
);
}
function MediaPreview({
fileId,
inlineVal,
inlineMime,
isVideo,
square,
}: {
fileId?: string;
inlineVal?: string;
inlineMime?: string;
isVideo: boolean;
square?: boolean;
}) {
// Real favorites carry the thumbnail inline as base64 (see favorites.ts's `Favorite` doc comment);
// `fileId` is kept as a fallback for a hypothetical external-ref-only variant, never observed live.
const inline = inlineVal ? `data:${inlineMime || 'image/jpeg'};base64,${inlineVal}` : undefined;
const remote = useAuthedBlobUrl(inline ? undefined : fileId ? `/v0/file/s/${fileId}` : undefined);
const src = inline || remote.url;
return (
<div className={`relative overflow-hidden rounded-lg bg-surface-rail ${square ? 'h-14 w-14' : 'h-[120px] w-full'}`}>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
</div>
)}
{isVideo && src ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
</div>
);
}
/**
* README §6.1 (3-column: Nav / 分类 260px / 内容). `favoriteMessages` (the create side, called from
* a message's context menu) lives in messages.ts; this is the read/manage side, new in this pass.
*/
export function FavoritesLayout() {
const queryClient = useQueryClient();
const [category, setCategory] = useState<FavoriteType | 'all'>('all');
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [forwarding, setForwarding] = useState(false);
const [error, setError] = useState('');
const favoritesQuery = useInfiniteQuery({
queryKey: ['favorites', category, debouncedQuery],
queryFn: ({ pageParam }) => getFavorites(category, debouncedQuery, pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = favoritesQuery.data?.pages.flatMap((p) => p.items) ?? [];
const invalidateAll = () => queryClient.invalidateQueries({ queryKey: ['favorites'] });
const deleteMutation = useMutation({
// Both API shapes are declared with an optional `failed[]` (favorites.ts:92) -- a 200 here only
// means the request was accepted, not that every selected item was actually deleted. Returning
// the real response (was previously discarded entirely) so onSuccess can check it, same pattern
// already fixed for BlocklistView's batch-unblock/GroupAdminModal elsewhere in this app.
mutationFn: async (ids: string[]) => {
if (ids.length === 1) {
await deleteFavorite(ids[0]);
return null;
}
return batchDeleteFavorites(ids);
},
onSuccess: (data) => {
invalidateAll();
// Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure --
// the error toast named a count ("N项删除失败") but gave no way to act on it, so retrying
// meant re-selecting from scratch by memory. Only actually-failed ids stay selected instead,
// primed for an immediate retry.
if (data?.failed?.length) {
setSelected(new Set(data.failed.map((f) => f.favorite_id)));
setError(`${data.failed.length} 项删除失败,请重试`);
} else {
exitSelectMode();
}
},
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
});
const forwardMutation = useMutation({
// forwardFavorites already accepts multiple target_topics + a comment natively -- this used to
// wrap a single topic in a 1-element array, silently discarding the picker's ability to select
// several recipients or attach a comment.
mutationFn: ({ targetTopics, comment }: { targetTopics: string[]; comment: string }) =>
forwardFavorites([...selected], targetTopics, comment),
onSuccess: (data) => {
setForwarding(false);
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
// succeed for 2 and fail for 1, and silently reporting "success" either way (including
// clearing the favorites selection) hid that AND left nothing to retry from. The picker
// itself still closes (which of several targets failed isn't tracked here) but the source
// favorites stay selected so re-opening "转发" immediately can retry against the same set.
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
else exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
});
// Switching category mid-multi-select used to leave the OLD category's selections in `selected`
// silently intact (nothing in this state ever cleared them) -- the header's "已选择 N 项" count and
// any 转发/删除 action would then silently include items the user can no longer even see. Category
// switches always start a fresh selection instead.
const switchCategory = (key: FavoriteType | 'all') => {
setCategory(key);
setSelected(new Set());
};
// Same reasoning as switchCategory above, for the OTHER thing that changes which items are even
// visible -- typing a search query re-filters the list just like picking a category does, and had
// the identical gap (nothing here ever cleared `selected` when the query changed).
const handleQueryChange = (value: string) => {
setQuery(value);
setSelected(new Set());
};
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const bulkDelete = async () => {
if (selected.size === 0) return;
if (!(await requestConfirm({ message: `删除已选择的 ${selected.size} 项收藏?`, danger: true }))) return;
deleteMutation.mutate([...selected]);
};
const categoryPicker = (
<div className="flex gap-2 overflow-x-auto px-4 pb-3">
{CATEGORIES.map((c) => (
<button
key={c.key}
type="button"
onClick={() => switchCategory(c.key)}
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-tertiary-1'
}`}
>
{c.label}
</button>
))}
</div>
);
const searchBox = (
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="搜索收藏内容"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
);
return (
<div className="flex h-full w-full">
{/* README §6.1's 3-column desktop layout (nav / 260px category rail / content). Below `md`
there's no room to dock a 260px rail next to content at all -- screens/05-favorites-
history.dc.html's own mobile mock (#1d) doesn't try to: it's a SINGLE screen with a
horizontal pill category-switcher inline above the list, not a separate "pick a category"
screen you navigate away from. This aside is desktop-only; its mobile equivalent is the
pill row rendered inline in the content column below. */}
<aside className="hidden h-full w-[260px] flex-none flex-col border-r border-border-panel bg-white md:flex">
<header className="flex h-15 flex-none items-center px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">我的收藏</h1>
</header>
<div className="mx-3 mb-2.5">{searchBox}</div>
<div className="flex flex-col gap-0.5 px-3">
{CATEGORIES.map((c) => (
<button
key={c.key}
type="button"
onClick={() => switchCategory(c.key)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm ${
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={c.icon} className={`text-[18px] ${category === c.key ? '' : 'text-text-tertiary-4'}`} />
<span className="flex-1 text-left">{c.label}</span>
</button>
))}
</div>
</aside>
<div className="flex h-full min-w-0 flex-1 flex-col bg-surface-content">
{selectMode ? (
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-4 md:px-6">
<span className="text-sm text-text-secondary">
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => setForwarding(true)}
disabled={selected.size === 0}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
>
<Icon name="reply" className="text-[18px]" />
转发
</button>
<button
type="button"
onClick={bulkDelete}
disabled={selected.size === 0}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-danger-border-soft px-3.5 text-[13px] text-danger-text disabled:opacity-40"
>
<Icon name="delete_outline" className="text-[18px]" />
删除
</button>
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
取消
</button>
</span>
</header>
) : (
<>
{/* Mobile-only header -- `/favorites` is only reachable on a narrow viewport via
设置(我的)>我的收藏 (SettingsLayout.tsx), so "back" returns there, matching every
other narrow-only-reachable-via-parent-list page's own back-arrow convention. */}
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[18px]" />
</Link>
<strong className="truncate text-center text-base font-semibold text-text-heading">我的收藏</strong>
{items.length ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : (
<span />
)}
</header>
<div className="flex-none border-b border-border-panel pt-3 md:hidden">
<div className="px-4">{searchBox}</div>
{categoryPicker}
</div>
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6 md:flex">
<strong className="text-base font-semibold text-text-heading">{CATEGORIES.find((c) => c.key === category)?.label}</strong>
{items.length ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : null}
</header>
</>
)}
<div className="flex-1 overflow-y-auto p-4 md:p-6">
{favoritesQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : favoritesQuery.isError ? (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-danger-text">
<Icon name="error_outline" className="!text-4xl" />
<span className="text-sm">加载失败,请重试</span>
<button type="button" onClick={() => favoritesQuery.refetch()} className="text-sm text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-text-tertiary-4">
<Icon name="inventory_2" className="!text-4xl" />
<span className="text-sm">暂无收藏内容</span>
</div>
) : (
<>
{/* 1 column under 640px (matches the mobile mock's plain vertical card list exactly),
3 at md+ (matches the desktop mock's 3-column grid); 2 in between is this app's own
judgment call, not in either mock, for the range where 1 column would waste width
and 3 would be as cramped as the bug this replaces. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 md:gap-4">
{items.map((item) => (
<FavoriteCard
key={item.favorite_id}
item={item}
selectMode={selectMode}
selected={selected.has(item.favorite_id)}
onToggle={() => toggle(item.favorite_id)}
/>
))}
</div>
{favoritesQuery.hasNextPage ? (
<div className="pt-4 text-center">
<button
type="button"
onClick={() => favoritesQuery.fetchNextPage()}
disabled={favoritesQuery.isFetchingNextPage}
className="text-sm text-accent disabled:opacity-60"
>
{favoritesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
</div>
) : null}
</>
)}
</div>
</div>
{forwarding ? (
<ForwardPickerModal
onClose={() => setForwarding(false)}
onPick={(targetTopics, comment) => forwardMutation.mutate({ targetTopics, comment })}
/>
) : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -1,217 +0,0 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { MAX_TITLE_LENGTH } from '@/lib/config';
import { useEscapeKey } from '@/lib/hooks';
interface CreateGroupModalProps {
onClose: () => void;
onCreated: (topic: string) => void;
}
/**
* README §4.7 (680x520): letter-grouped friend picker on the left, selected chips + group-name
* input on the right. Grouped by the first character of the display name -- a true pinyin-initial
* grouping (matching the reference's A/B/C-style index) would need a pinyin library this codebase
* doesn't have anywhere else; first-character buckets are a reasonable stand-in and already correct
* for the plain-ASCII usernames this backend's test accounts actually have.
*/
export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Map<string, string>>(new Map());
const [name, setName] = useState('');
const [joinApproval, setJoinApproval] = useState(false);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
useEscapeKey(onClose);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friends = friendsQuery.data || [];
const groups = useMemo(() => {
const trimmed = query.trim().toLowerCase();
const filtered = friends.filter((f) => !trimmed || (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmed));
const byLetter = new Map<string, typeof friends>();
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || a.user_id).localeCompare(b.remark || b.display_name || b.user_id))) {
const label = f.remark || f.display_name || f.user_id;
const letter = /[A-Za-z]/.test(label[0] || '') ? label[0].toUpperCase() : '#';
if (!byLetter.has(letter)) byLetter.set(letter, []);
byLetter.get(letter)!.push(f);
}
return [...byLetter.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [friends, query]);
const toggle = (userId: string, label: string) => {
setSelected((prev) => {
const next = new Map(prev);
if (next.has(userId)) next.delete(userId);
else next.set(userId, label);
return next;
});
};
const submit = async () => {
if (selected.size === 0 || submitting) return;
setSubmitting(true);
setError('');
let result: Awaited<ReturnType<typeof createGroup>>;
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a combination
// of member nicknames when left blank), but `name` has no such fallback server-side (unlike
// `description`, which the protocol doc explicitly allows empty) -- confirmed live: submitting a
// blank name gets rejected outright with MALFORMED_REQUEST, not silently defaulted. Declared
// outside the try block since the catch below needs it too.
const usedAutoName = !name.trim();
try {
// Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when
// it had to cut names short, so this can't itself produce an over-length name the server would
// also reject.
const trimmedName = name.trim();
let finalName = trimmedName;
if (!finalName) {
const joined = [...selected.values()].join('、');
finalName = joined.length > MAX_TITLE_LENGTH ? `${joined.slice(0, MAX_TITLE_LENGTH - 1)}…` : joined;
}
result = await createGroup({
name: finalName,
member_user_ids: [...selected.keys()],
});
} catch (err) {
// Real, live-confirmed gap (Zentao #26): a name the user never typed themselves -- the
// "未填写时默认为成员昵称组合" fallback just above -- can still get rejected (e.g. content
// moderation flagging a purely-numeric nickname combination), and the generic error message
// gives someone who left the field blank no clue that "the name" is even what's wrong, let
// alone that they can fix it by typing one in themselves.
if (usedAutoName) {
setError('自动生成的群名未通过审核,请手动输入一个群名后重试');
} else {
setError(err instanceof Error ? err.message : '创建群聊失败');
}
setSubmitting(false);
return;
}
// The group now genuinely exists -- from here on, a failure is NOT "创建群聊失败" (which would
// read as "nothing happened, try again" and could prompt a duplicate re-submit). Kept in its own
// try/catch so the 进群验证 follow-up call failing can't get blamed on group creation itself.
if (joinApproval) {
try {
// README §4.5's 进群验证 governance toggle defaults off; a group created with "开启进群验证"
// checked needs it turned on right after creation since createGroup's own body has no field
// for it (docs/IM-前后端对接文档.md:1445's create body is name/description/member_user_ids/
// avatar_file_id only -- governance is a separate resource).
const gov = await getGroupGovernance(result.topic);
await updateGroupGovernance(result.topic, { join_approval: true, version: gov.version });
} catch {
// Non-fatal: the group is real and usable, just without 进群验证 turned on yet -- that's a
// setting the owner can still flip from 群管理 afterward, not a reason to hide a real group.
}
}
setSubmitting(false);
onCreated(result.topic);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[85vh] w-full flex-col rounded-xl bg-white shadow-modal md:h-[520px] md:w-[680px]" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">创建群聊</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex flex-1 flex-col overflow-hidden md:flex-row">
<div className="flex w-full flex-1 flex-col border-b border-border-list-item md:w-[340px] md:flex-none md:border-b-0 md:border-r">
<div className="flex-none p-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索联系人"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{friendsQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : groups.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">未找到联系人</div>
) : (
groups.map(([letter, items]) => (
<div key={letter}>
<div className="px-2 pb-1 pt-2 text-xs text-text-tertiary-3">{letter}</div>
{items.map((f) => {
const label = f.remark || f.display_name || f.user_id;
const checked = selected.has(f.user_id);
return (
<button
key={f.user_id}
type="button"
onClick={() => toggle(f.user_id, label)}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-surface-rail"
>
<SelectionCheckbox selected={checked} />
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}
>
{avatarLetter(label)}
</span>
<span className="truncate text-sm text-text-body">{label}</span>
</button>
);
})}
</div>
))
)}
</div>
</div>
<div className="flex flex-1 flex-col p-4">
<div className="flex-none text-sm text-text-secondary">已选 {selected.size} 人</div>
<div className="mt-2 flex flex-wrap content-start gap-2 overflow-y-auto" style={{ maxHeight: 220 }}>
{[...selected.entries()].map(([userId, label]) => (
<span key={userId} className="flex items-center gap-1 rounded-2xl bg-surface-rail py-1 pl-1 pr-2">
<span className={`grid h-[22px] w-[22px] place-items-center rounded-full text-[11px] text-white ${avatarBgClass(userId)}`}>
{avatarLetter(label)}
</span>
<span className="max-w-[100px] truncate text-xs text-text-body">{label}</span>
<button type="button" onClick={() => toggle(userId, label)} className="grid h-4 w-4 place-items-center text-text-tertiary-3">
<Icon name="close" className="text-[14px]" />
</button>
</span>
))}
</div>
<div className="mt-auto flex flex-col gap-3">
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
<input
value={name}
onChange={(e) => setName(e.target.value.slice(0, MAX_TITLE_LENGTH))}
placeholder="未填写时默认为成员昵称组合"
className="h-10 rounded-lg bg-surface-input px-3 text-sm outline-none placeholder:text-text-placeholder-1"
/>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={joinApproval} onChange={(e) => setJoinApproval(e.target.checked)} className="accent-accent" />
开启进群验证
</label>
<button
type="button"
onClick={submit}
disabled={selected.size === 0 || submitting}
className="h-10 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '创建中…' : `创建群聊 (${selected.size})`}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
-516
View File
@@ -1,516 +0,0 @@
import { useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { privateChatOption, privateChatOptions } from '@/features/groups/group-settings';
import {
addGroupAdmins,
getAllGroupMembers,
getGroup,
getGroupGovernance,
getAllGroupMutes,
getGroupRoleLimits,
getGroupSummary,
muteGroupMembers,
removeGroupAdmin,
transferGroupOwner,
unmuteGroupMember,
updateGroupGovernance,
type GroupMember,
type GroupMute,
} from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { requestConfirm } from '@/stores/confirmDialog';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
interface GroupAdminModalProps {
topicId: string;
onClose: () => void;
}
function SegmentedControl<T extends string>({
value,
options,
onChange,
disabled,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
disabled?: boolean;
}) {
return (
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{options.map((opt) => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => onChange(opt.value)}
className={`px-3 py-1 disabled:opacity-50 ${value === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
);
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
</button>
);
}
type SubView = 'main' | 'admins' | 'mutes' | 'join-requests' | 'transfer';
/** README §4.5 (560px): governance toggles/segments + admins/mutes/join-requests/owner-transfer link
* rows. `addGroupAdmins`/`removeGroupAdmin`/`transferGroupOwner` all share ONE optimistic-lock
* counter with the group profile itself -- confirmed by the doc's own worked example (§6.29):
* admins/batch takes version:1 and returns version:2, then owner-transfer takes version:2 and
* returns version:3. So the current value always comes from `getGroup`, re-read (via query
* invalidation) after every such mutation -- a hardcoded constant would only be correct once. */
export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const [view, setView] = useState<SubView>('main');
const [error, setError] = useState('');
useEscapeKey(onClose);
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
// README §4.5: 转让群主仅群主可见 -- `GroupPermissions` has no dedicated `can_transfer_ownership`
// field, but `GroupProfile.owner` (the real owner's user id) is already fetched here for the
// shared version counter, so comparing it against the session's own id is free, no extra request.
const isOwner = !!myUserId && groupQuery.data?.owner === myUserId;
const govQuery = useQuery({ queryKey: ['group-governance', topicId], queryFn: () => getGroupGovernance(topicId) });
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
const roleLimitsQuery = useQuery({ queryKey: ['group-role-limits', topicId], queryFn: () => getGroupRoleLimits(topicId) });
// Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupAdmins): the entire
// `/admins*` surface -- list included, not just add/remove -- requires OWNER, a plain admin gets
// 403 even just listing. `enabled: isOwner` stops a non-owner admin from firing a request that can
// only ever fail, and pairs with the `isOwner` gate on the "群管理员" row itself below.
const adminsQuery = useQuery({
queryKey: ['group-members', topicId, 'admin'],
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
enabled: isOwner,
});
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getAllGroupMutes(topicId) });
const govMutation = useMutation({
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
onSuccess: (data) => queryClient.setQueryData(['group-governance', topicId], data),
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
});
const setGov = (
patch: Partial<{ join_approval: boolean; mute_all: boolean; member_upload_files: boolean; member_private_chat: boolean; member_invite: boolean }>,
) => {
if (!govQuery.data) return;
govMutation.mutate({ ...patch, version: govQuery.data.version });
};
const maxAdminCount = roleLimitsQuery.data?.max_admin_count;
if (view === 'join-requests') return <JoinRequestsModal topicId={topicId} onClose={() => setView('main')} />;
if (view === 'admins')
return (
<AdminsSubView
topicId={topicId}
admins={adminsQuery.data || []}
maxAdminCount={maxAdminCount}
groupVersion={groupQuery.data?.version}
onClose={() => setView('main')}
/>
);
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data || []} onClose={() => setView('main')} />;
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex max-h-[80vh] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">群管理</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-2 text-sm">
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<div className="flex flex-col">
<span className="text-text-body">进群验证</span>
<span className="text-xs text-text-tertiary-3">开启后新成员需管理员审核</span>
</div>
<Toggle
checked={!!govQuery.data?.join_approval}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.join_approval_editable === false}
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<div className="flex flex-col">
<span className="text-text-body">全员禁言</span>
<span className="text-xs text-text-tertiary-3">仅群主和管理员可以发言</span>
</div>
<Toggle
checked={!!govQuery.data?.mute_all}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.mute_all_editable === false}
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">群文件上传权限</span>
<SegmentedControl
value={govQuery.data?.member_upload_files ? 'all' : 'admin'}
options={[
{ value: 'all', label: '所有成员' },
{ value: 'admin', label: '仅管理员' },
]}
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_upload_files_editable === false}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">成员私聊权限</span>
<SegmentedControl
value={privateChatOption(!!govQuery.data?.member_private_chat)}
options={privateChatOptions}
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
/>
</div>
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
real, already-read/write-capable field with no row anywhere in 03-group.dc.html's own
群管理弹层 mock (#1b only draws 4 governance rows) -- placed here as a 5th row using the
SAME SegmentedControl treatment as 群文件上传权限/成员私聊权限 just above (same modal,
same "who is allowed to do X" semantics), rather than inventing a new visual pattern. */}
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">邀请入群权限</span>
<SegmentedControl
value={govQuery.data?.member_invite ? 'all' : 'admin'}
options={[
{ value: 'all', label: '所有成员' },
{ value: 'admin', label: '仅管理员' },
]}
onChange={(v) => setGov({ member_invite: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_invite_editable === false}
/>
</div>
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
entire sub-resource (list included) requires ownership, not just admin. */}
{isOwner ? (
<button type="button" onClick={() => setView('admins')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">群管理员</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
<span className="text-xs">
{adminsQuery.data?.length ?? 0}
{maxAdminCount ? `/${maxAdminCount}` : ''}
</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
) : null}
<button type="button" onClick={() => setView('mutes')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">禁言成员</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
<span className="text-xs">{mutesQuery.data?.length ?? 0} 人</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
<button type="button" onClick={() => setView('join-requests')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">进群申请</span>
<span className="flex items-center gap-1">
{summaryQuery.data?.pending_join_request_count ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{summaryQuery.data.pending_join_request_count}
</b>
) : null}
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
{isOwner ? (
<button type="button" onClick={() => setView('transfer')} className="flex w-full items-center justify-between py-3.5 text-left">
<span className="text-text-body">转让群主</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</button>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}
/** Shared candidate list for the admin/mute/owner-transfer pickers. All three server endpoints
* follow the same "候选" shape (doc §6.19/6.23/6.25): `GET .../members` with an `eligible_role=` or
* `mute_eligible=` filter, whose response items still carry `eligible` for entries the caller can't
* act on -- so this filters those out client-side rather than trusting the query param alone to
* have excluded them server-side. */
function MemberPickerList({
topicId,
cacheKey,
opts,
onPick,
emptyLabel,
disabled,
}: {
topicId: string;
cacheKey: string;
opts: Parameters<typeof getAllGroupMembers>[1];
onPick: (m: GroupMember) => void;
emptyLabel: string;
disabled?: boolean;
}) {
const membersQuery = useQuery({ queryKey: ['group-members', topicId, cacheKey], queryFn: () => getAllGroupMembers(topicId, opts) });
const candidates = (membersQuery.data || []).filter((m) => m.eligible !== false);
if (membersQuery.isLoading) return <div className="py-6 text-center text-sm text-text-tertiary-4">加载中…</div>;
if (candidates.length === 0) return <div className="py-6 text-center text-sm text-text-tertiary-4">{emptyLabel}</div>;
return (
<>
{candidates.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => onPick(m)}
disabled={disabled}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:opacity-50"
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(groupMemberDisplayName(m))}
</span>
<span className="truncate text-sm text-text-body">{groupMemberDisplayName(m)}</span>
</button>
))}
</>
);
}
function SubModalShell({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center gap-2 border-b border-border-list-item px-3">
<button type="button" onClick={onClose} className="grid h-8 w-8 flex-none place-items-center text-text-tertiary-4">
<Icon name="arrow_back" />
</button>
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">{children}</div>
</div>
</div>
);
}
function AdminsSubView({
topicId,
admins,
maxAdminCount,
groupVersion,
onClose,
}: {
topicId: string;
admins: GroupMember[];
maxAdminCount: number | undefined;
groupVersion: number | undefined;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const [error, setError] = useState('');
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
// `group-members-preview` is a SEPARATE cache key (GroupDetailColumn.tsx's own 9-grid query) --
// missing it here meant promoting/demoting an admin never refreshed that grid's own "主"/"管"
// corner badges until something else happened to invalidate it.
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-role-limits', topicId] });
};
const addMutation = useMutation({
mutationFn: (userId: string) => addGroupAdmins(topicId, [userId], groupVersion ?? 1),
onSuccess: () => {
invalidate();
setAdding(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '添加失败'),
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupAdmin(topicId, userId, groupVersion ?? 1),
onSuccess: invalidate,
onError: (err) => setError(err instanceof Error ? err.message : '移除失败'),
});
const countLabel = maxAdminCount ? `${admins.length}/${maxAdminCount}` : `${admins.length}`;
useEscapeKey(onClose);
return (
<SubModalShell title={adding ? '添加管理员' : `群管理员 ${countLabel}`} onClose={adding ? () => setAdding(false) : onClose}>
{adding ? (
<MemberPickerList
topicId={topicId}
cacheKey="eligible-admin"
opts={{ eligibleRole: 'admin' }}
onPick={(m) => addMutation.mutate(m.user_id)}
emptyLabel="没有可添加的成员"
disabled={addMutation.isPending}
/>
) : (
<>
{admins.map((a) => (
<div key={a.user_id} className="flex items-center gap-3 px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(a.user_id)}`}>
{avatarLetter(groupMemberDisplayName(a))}
</span>
<span className="flex-1 truncate text-sm text-text-body">{groupMemberDisplayName(a)}</span>
<button
type="button"
onClick={() => removeMutation.mutate(a.user_id)}
disabled={removeMutation.isPending}
className="text-xs text-danger-text disabled:opacity-50"
>
移除
</button>
</div>
))}
{(() => {
const atLimit = !!maxAdminCount && admins.length >= maxAdminCount;
return (
<button
type="button"
onClick={() => setAdding(true)}
disabled={atLimit}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent disabled:text-text-tertiary-3"
>
<Icon name="add" className="text-[18px]" />
{atLimit ? '已达管理员人数上限' : '添加管理员'}
</button>
);
})()}
</>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
</SubModalShell>
);
}
function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: GroupMute[]; onClose: () => void }) {
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const [error, setError] = useState('');
// `GroupMute` itself carries only a bare `user_id` (see its own type comment) -- resolve a real
// name/avatar the same way ConversationView.tsx's `memberByUserId` does, rather than rendering the
// raw id (this list previously crashed outright reading a `.user.display_name` that never existed
// on the real response, confirmed live: `Cannot read properties of undefined (reading 'user_id')`
// the moment a real muted member was ever rendered here).
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const memberByUserId = new Map((membersQuery.data || []).map((m) => [m.user_id, m]));
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
const muteMutation = useMutation({
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
onSuccess: () => {
invalidate();
setAdding(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '禁言失败'),
});
const unmuteMutation = useMutation({
mutationFn: (userId: string) => unmuteGroupMember(topicId, userId),
onSuccess: invalidate,
onError: (err) => setError(err instanceof Error ? err.message : '解除禁言失败'),
});
useEscapeKey(onClose);
return (
<SubModalShell title={adding ? '选择禁言成员' : `禁言成员 ${muted.length}`} onClose={adding ? () => setAdding(false) : onClose}>
{adding ? (
<MemberPickerList
topicId={topicId}
cacheKey="mute-eligible"
opts={{ muteEligible: true }}
onPick={(m) => muteMutation.mutate(m.user_id)}
emptyLabel="没有可禁言的成员"
disabled={muteMutation.isPending}
/>
) : (
<>
{muted.map((m) => {
const member = memberByUserId.get(m.user_id);
const name = member ? groupMemberDisplayName(member) : m.user_id;
return (
<div key={m.user_id} className="flex items-center gap-3 px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{name}</span>
<button
type="button"
onClick={() => unmuteMutation.mutate(m.user_id)}
disabled={unmuteMutation.isPending}
className="text-xs text-accent disabled:opacity-50"
>
解除禁言
</button>
</div>
);
})}
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
禁言成员
</button>
</>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
</SubModalShell>
);
}
function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: string; groupVersion: number | undefined; onClose: () => void }) {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const transferMutation = useMutation({
mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) =>
transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
// Same gap as AdminsSubView's own invalidate() -- the 9-grid "主" badge in GroupDetailColumn
// reads this separate cache key, not `group-members`.
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
onClose();
},
onError: (err) => setError(err instanceof Error ? err.message : '转让群主失败'),
});
useEscapeKey(onClose);
return (
<SubModalShell title="转让群主" onClose={onClose}>
<MemberPickerList
topicId={topicId}
cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }}
onPick={async (m) => {
if (!(await requestConfirm({ message: `确定将群主转让给 ${groupMemberDisplayName(m)} 吗?此操作不可撤销。`, danger: true }))) return;
const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
if (!actionToken) return;
transferMutation.mutate({ userId: m.user_id, actionToken });
}}
emptyLabel="没有可转让的成员"
disabled={transferMutation.isPending}
/>
<Toast message={error} variant="error" onDone={() => setError('')} />
</SubModalShell>
);
}
@@ -1,162 +0,0 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getAllGroupMembers, getLatestGroupAnnouncement, readGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useEscapeKey } from '@/lib/hooks';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
interface GroupAnnouncementModalProps {
topicId: string;
canEdit: boolean;
onClose: () => void;
}
/**
* README §4.8 (480px): publisher avatar/name/role tag + publish time, body text, read count, and an
* 编辑 affordance for owner/admin. `PUT .../announcements/current` is optimistic-locked by
* `version`, confirmed present in the GET response (doc line 1745, and re-confirmed live). Two
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
* -- resolved here against the group's member list, same as any other bare user id in this API.
* When nothing has been published yet, the endpoint still returns `200` with every field
* zero-valued, never `404`/`null` -- checked below via `content` emptiness, not object existence (a
* real bug this file had before this fix: `!query.data` was never true, so it rendered a blank
* "announcement" -- empty name, empty date -- instead of the empty state).
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
* added them (see groups.ts's own comment on `GroupAnnouncement`), so this now renders "已读N/M" as
* README §4.8 originally called for, and calls `readGroupAnnouncement` on open -- that REST endpoint
* existed in groups.ts since before this fix but was never actually called from anywhere, so opening
* an announcement never told the server "this member has read it" at all.
*/
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [error, setError] = useState('');
useEscapeKey(onClose);
const query = useQuery({ queryKey: ['group-announcement', topicId], queryFn: () => getLatestGroupAnnouncement(topicId) });
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
// Fire-and-forget: viewing the announcement IS "reading" it, matching how the P2P/group message
// read receipt (native `{note what:"read"}`) already works elsewhere in this app -- no explicit
// "mark as read" button anywhere in the mock. Guarded on a real, non-empty `announcement_id` so
// this doesn't fire for the zero-valued "nothing published yet" response, and keyed on that id (not
// just "modal opened") so re-opening the SAME announcement doesn't re-send needlessly, though the
// endpoint's own idempotency key means it would be harmless either way.
const announcementId = query.data?.announcement_id;
useEffect(() => {
if (!announcementId) return;
// Invalidate (not just fire-and-forget) so THIS viewer's own read immediately bumps the
// displayed count instead of showing a stale pre-read number until some unrelated refetch.
readGroupAnnouncement(topicId, announcementId)
.then(() => queryClient.invalidateQueries({ queryKey: ['group-announcement', topicId] }))
.catch(() => {});
}, [topicId, announcementId, queryClient]);
const saveMutation = useMutation({
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
onSuccess: (data) => {
queryClient.setQueryData(['group-announcement', topicId], data);
// `GET .../settings` genuinely has no `announcement` field (see GroupDetailColumn.tsx's own
// comment on this) -- the detail rail's own announcement preview actually reads
// `group-summary`, so invalidating `group-settings` here never refreshed it; the preview text
// was stuck at whatever it was before this save until something ELSE happened to touch summary.
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
setEditing(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '发布失败'),
});
const startEdit = () => {
setDraft(query.data?.content || '');
setEditing(true);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex max-h-[70vh] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">群公告</strong>
<div className="flex items-center gap-3">
{canEdit && !editing ? (
<button type="button" onClick={startEdit} className="text-sm text-accent">
{query.data?.content ? '编辑' : '发布'}
</button>
) : null}
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto px-5 py-4">
{editing ? (
<div className="flex flex-col gap-3">
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={8}
placeholder="发布群公告,所有成员都会收到通知"
className="resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
/>
<button
type="button"
onClick={() => saveMutation.mutate(draft.trim())}
disabled={!draft.trim() || saveMutation.isPending}
className="h-10 self-end rounded-lg bg-btn px-5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
{saveMutation.isPending ? '发布中…' : '发布'}
</button>
</div>
) : query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !query.data?.content ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群公告</div>
) : (
<>
<div className="flex items-center gap-2">
<span className={`grid h-8 w-8 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(query.data.author)}`}>
{avatarLetter(author?.display_name || query.data.author)}
</span>
<span className="text-sm font-medium text-text-body">{author?.display_name || query.data.author}</span>
{/* README §4.3: border style, no fill (matches MessageBubble.tsx's MessageRoleTag) --
this used to hardcode warn/orange for BOTH owner and admin authors, so an admin's
own announcement showed the wrong (owner) color. */}
{author?.role === 'owner' ? (
<span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">{ROLE_LABEL.owner}</span>
) : author?.role === 'admin' ? (
<span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">{ROLE_LABEL.admin}</span>
) : null}
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
</div>
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
{/* README §4.8. Both the flat fields and `read_summary` carry the same two numbers
(confirmed reading the backend's own payload builder) -- prefer the flat ones,
fall back to the nested object in case a future response only sends one shape. */}
{(() => {
const readCount = query.data.read_count ?? query.data.read_summary?.read_count;
const memberCount = query.data.member_count ?? query.data.read_summary?.member_count;
if (readCount == null || memberCount == null) return null;
return (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">
已读 {readCount}/{memberCount}
</p>
);
})()}
</>
)}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}
@@ -1,620 +0,0 @@
import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { GroupAdminModal } from '@/features/groups/GroupAdminModal';
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
import { canEditGroupAvatar, groupAvatarError } from '@/features/groups/group-settings';
import {
canRemoveMember,
disbandGroup,
getGroup,
getMyGroupProfile,
getGroupMembers,
getGroupSettings,
getGroupSummary,
leaveGroup,
updateGroup,
updateGroupMemberSettings,
updateMyGroupNickname,
type GroupMember,
type GroupProfile,
type GroupSettings,
} from '@/lib/api/groups';
import type { MediaType } from '@/lib/api/messages';
import { uploadFile } from '@/lib/api/upload';
import { MAX_TITLE_LENGTH } from '@/lib/config';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { useTinode } from '@/lib/tinode/hooks';
import { forgetTopic } from '@/lib/tinode/topics';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
import { openUserCard } from '@/stores/userCard';
interface GroupDetailColumnProps {
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
// README §4.10/#1e: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
// (18×18 knob) -- this used to be the desktop size unconditionally. The travel distance
// happens to stay 18px at both sizes (both track and knob grow by the same 4px, so the gap
// between them is unchanged), so only the track/knob dimensions need a `md:` fork, not the
// translate value.
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
/>
</button>
);
}
/** README §4.1's owner/admin corner tags on the 5-col member grid. */
function RoleCorner({ role }: { role: GroupMember['role'] }) {
if (role === 'owner') return <span className="absolute -left-0.5 -top-0.5 rounded bg-warn px-1 text-[9px] text-white">主</span>;
if (role === 'admin') return <span className="absolute -left-0.5 -top-0.5 rounded bg-accent px-1 text-[9px] text-white">管</span>;
return null;
}
/**
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
* almost entirely REST per Ch.6 of the protocol doc, not native Tinode `{set}` like a P2P topic's
* desc/private would use -- confirmed against the old app's actually-shipped group-management-view.jsx).
*/
export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: GroupDetailColumnProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const tinode = useTinode();
const myUserId = useSessionStore((s) => s.myUserId);
const [nicknameEditing, setNicknameEditing] = useState(false);
const [nicknameDraft, setNicknameDraft] = useState('');
// `updateGroup` (lib/api/groups.ts) has always supported renaming a group -- confirmed live no UI
// anywhere ever called it (grep across the whole repo: zero call sites), so a group's name was
// fixed forever at whatever was typed into CreateGroupModal's input at creation time, with no way
// to fix a typo or rename it later. Owner-only, matching this file's other high-consequence group-
// level (as opposed to per-member) actions.
const [nameEditing, setNameEditing] = useState(false);
const [nameDraft, setNameDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
// GroupDetailColumn is reused across every group when docked, no `key={topicId}`.
useLayoutEffect(() => {
setNicknameEditing(false);
setNicknameDraft('');
setNameEditing(false);
setNameDraft('');
setErrorToast('');
setOpenModal(null);
}, [topicId]);
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
const settingsQuery = useQuery({ queryKey: ['group-settings', topicId], queryFn: () => getGroupSettings(topicId) });
// `GET .../settings`'s real response has no `announcement` field at all (confirmed live,
// 2026-09-08) despite this file originally typing one on GroupSettings -- the summary endpoint
// is the one that actually carries it (also used by ConversationView's notification bar and
// GroupAdminModal's join-request badge, so this is a cache hit more often than not).
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
const membersQuery = useQuery({ queryKey: ['group-members-preview', topicId], queryFn: () => getGroupMembers(topicId, {}) });
// The nickname row needs the per-user profile endpoint. Uses the
// dedicated `members/me/profile` route, not getGroupMemberProfile(topic, myUserId) -- see that
// function's own warning for why the generic by-id path 400s on self-lookups.
const myProfileQuery = useQuery({
queryKey: ['group-my-profile', topicId, myUserId],
queryFn: () => getMyGroupProfile(topicId),
enabled: !!myUserId,
});
const myRole = settingsQuery.data?.my_role;
const isOwner = myRole === 'owner';
const isAdmin = myRole === 'admin' || isOwner;
const canEditAvatar = canEditGroupAvatar(
settingsQuery.data?.permissions?.can_edit_profile ?? groupQuery.data?.permissions?.can_edit_profile,
myRole,
);
// GroupAdminModal's "邀请入群权限" governance toggle (所有成员/仅管理员) was added without ever
// being read anywhere -- every real gate on the invite affordance stayed hardcoded to
// owner/admin-only, so flipping that toggle to "所有成员" had zero effect on what a plain member
// could actually see. `member_invite` lives on this same `settingsQuery` response already in scope
// (no extra request needed) -- live-verified this reflects the real value (flipped it via
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true;
// Same gap as `canInvite` right above (same governance toggle pattern, same root cause: the
// "群文件上传权限" toggle in GroupAdminModal writes `member_upload_files` but nothing ever read it
// back) -- `GroupFilesModal`'s upload button was unconditionally visible/clickable regardless of
// this setting.
const canUpload = isAdmin || settingsQuery.data?.governance?.member_upload_files === true;
// No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending
// one 400s every single call (this was the actual root cause of "member-settings always 400",
// not an optimistic-lock issue -- confirmed live 2026-09-09 by reading the backend's request
// struct, which doesn't declare a version field at all).
const updateSettings = useMutation({
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) => updateGroupMemberSettings(topicId, patch),
// The response is the small {pinned, mute_notifications, version, updated_at} shape, not a full
// GroupSettings -- merge instead of replacing, or every other cached field (name/owner/governance/
// member_count/...) would vanish from the settings panel after the first successful toggle.
onSuccess: (data) =>
queryClient.setQueryData(['group-settings', topicId], (prev: GroupSettings | undefined) =>
prev ? { ...prev, pinned: data.pinned, mute_notifications: data.mute_notifications } : prev,
),
onError: (err) => showError(err, '设置失败,请重试'),
});
const nicknameMutation = useMutation({
mutationFn: (nickname: string) => updateMyGroupNickname(topicId, nickname, myProfileQuery.data?.version ?? 1),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicId, myUserId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
setNicknameEditing(false);
},
onError: (err) => showError(err, '昵称保存失败'),
});
const nameMutation = useMutation({
mutationFn: (name: string) => updateGroup(topicId, { name, version: groupQuery.data?.version ?? 1 }),
// Merge, not replace -- same defensive reasoning as updateSettings above, in case this endpoint
// ever turns out to return a partial object like member-settings did.
onSuccess: (data) => {
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
setNameEditing(false);
// The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from
// this REST call either -- only from the `group.settings.updated` push topics.ts listens for,
// which this account won't receive for its own action. `data` here is already the fresh REST
// response, so apply it locally via `_processMetaDesc` (live-confirmed to reach `me.contacts()`,
// see topics.ts's comment on the same call) instead of an extra `getMeta(...withDesc())` round
// trip -- that SDK-sanctioned path was live-instrumented and found to reliably time out (504) or
// return stale data against this backend, so it's not used anywhere in this fix anymore.
tinode.getTopic(topicId)._processMetaDesc({ public: { fn: data.name, name: data.name } });
},
onError: (err) => showError(err, '群名保存失败'),
});
const avatarMutation = useMutation({
mutationFn: async ({ file, topic, version }: { file: File; topic: string; version: number }) => {
const uploaded = await uploadFile(file, undefined, 'group_avatar');
return { ...(await updateGroup(topic, { avatar_file_id: uploaded.file_id, version })), avatar_file_id: uploaded.file_id };
},
onSuccess: (data, { topic }) => {
queryClient.setQueryData(['group', topic], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['group', topic] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topic] });
queryClient.invalidateQueries({ queryKey: ['groups', 'all'] });
},
onError: (err) => showError(err, '群头像修改失败'),
});
const avatarUploading = avatarMutation.isPending && avatarMutation.variables?.topic === topicId;
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const error = groupAvatarError(file);
if (error) setErrorToast(error);
else if (groupQuery.data) avatarMutation.mutate({ file, topic: topicId, version: groupQuery.data.version });
};
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
// stub for this group after leaving/disbanding it (same lingering-stub symptom `forgetTopic` was
// originally built to fix for the refresh/404 case, just reached via a third path); (2) that same
// late signal, left unguarded, used to re-fire ConversationView's generic "该群聊已解散" toast on
// top of this action's own navigate -- wrong wording specifically for 退出群聊 (leaving yourself
// isn't the group being dissolved). `markTopicGoneAndNotify`'s own guard (topics.ts) now skips that
// late signal entirely once `forgetTopic` has already marked this name gone here.
const leaveMutation = useMutation({
mutationFn: () => leaveGroup(topicId),
onSuccess: () => {
forgetTopic(topicId);
navigate('/messages');
},
onError: (err) => showError(err, '退出群聊失败'),
});
const disbandMutation = useMutation({
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
onSuccess: () => {
forgetTopic(topicId);
navigate('/messages');
},
onError: (err) => showError(err, '解散群聊失败'),
});
const clearHistory = async () => {
// See DetailColumn.tsx's identical clearHistory for why this copy changed: a soft delete
// (`delMessagesAll(false)`) is a real, per-account server watermark (im_be's Topic.replyDelMsg),
// not a local-only cache wipe a refresh or another device would undo.
if (
!(await requestConfirm({
message: '确定清空本群的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响其他成员。',
danger: true,
}))
)
return;
try {
await tinode.getTopic(topicId).delMessagesAll(false);
} catch (err) {
showError(err, '清空聊天记录失败,请重试');
}
};
const reportAbuse = () => {
notify('该功能暂未开放,如需投诉举报请联系客服。');
};
const startLeaveOrDisband = async () => {
if (isOwner) {
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
const actionToken = await requestActionToken({
scene: 'group_dissolve',
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
});
if (!actionToken) return;
disbandMutation.mutate(actionToken);
} else {
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
leaveMutation.mutate();
}
};
const members = membersQuery.data?.items || [];
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
// Real gap this fixes: `isAdmin` alone gates the "-移出" tile below, but canRemoveMember (the
// per-row check the tile's own destination list uses) is strictly finer -- an admin can't remove
// the owner or another admin, only the owner can remove an admin. A group with no plain members
// (e.g. [owner, admin(me), admin(other)]) would show the tile yet land on a list with zero "移出"
// buttons anywhere, a dead end. Checked against the same preview page the compact grid above
// already renders from (not exhaustive for a group with more members than that page holds, but
// that's the same tradeoff this component's own member grid already makes, not a new one).
const canRemoveAnyone = isAdmin && members.some((m) => canRemoveMember(myUserId, myRole || 'member', m));
return (
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
{/* Desktop header (03-group.dc.html #1a): title left, close-X right -- unchanged. */}
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
<strong className="text-[15px] font-semibold">群聊设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
) : null}
</header>
{/* Mobile header (#1e, "详情栏内容平铺为整页"): back arrow / centered title / empty. This panel
already renders full-screen below 960px (MessagesLayout's own `fixed inset-0 md:hidden`
wrapper around ConversationDetailPanel) -- only its OWN header/content never got restyled
to match until now. `onClose` doubles as "back": same prop the desktop close-X already
calls, which just closes this full-screen overlay -- no separate navigation needed. */}
<header className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-list-item bg-white px-2 md:hidden">
{onClose ? (
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[22px]" />
</button>
) : (
<span />
)}
<strong className="text-center text-[17px] font-semibold">群聊设置</strong>
<span />
</header>
{/* Content. Desktop: `md:contents` makes this wrapper (and the "card" wrapper just below it)
disappear as boxes -- their children fall through to be direct flex children of `aside`
exactly as before this change, so desktop stays pixel-identical. Mobile: a real scroll
container (the `aside` above switches to `overflow-hidden`, handing scrolling to this
instead) with the mock's `#f6f7f9` page background and 10px gaps between white "cards".
The card GROUPING genuinely differs from desktop here, confirmed by reading both #1a and
#1e side by side rather than assumed: mobile puts 群管理 with 群公告/群文件/查找聊天记录
(not with 清空聊天记录/投诉举报 like desktop), and mobile's mock has no 我在本群的昵称 row
at all -- kept anyway (see below) since it's a real, working desktop feature with no other
entry point on mobile; silently dropping it would be a functional regression, not a
faithful mobile port. */}
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto bg-[#f6f7f9] py-2.5 md:contents">
<div className="bg-white md:contents">
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
<div className="flex items-center gap-3">
<div className="relative flex-none">
<Avatar
id={topicId}
name={groupQuery.data?.name || peerName}
avatarFileId={groupQuery.data?.avatar_file_id}
className="h-13 w-13 rounded-xl text-xl"
/>
{canEditAvatar && groupQuery.data ? (
<label className="absolute -bottom-1 -right-1 grid h-6 w-6 cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm" aria-label="修改群头像">
{avatarUploading ? <span className="text-[9px]">上传中</span> : <Icon name="photo_camera" className="text-[14px]" />}
<input type="file" accept="image/*" onChange={handlePickAvatar} disabled={avatarUploading || nameMutation.isPending} className="hidden" />
</label>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col">
{nameEditing ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && !avatarUploading && nameMutation.mutate(nameDraft.trim())}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button
type="button"
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
disabled={!nameDraft.trim() || nameMutation.isPending || avatarUploading}
className="flex-none text-sm text-accent disabled:opacity-40"
>
保存
</button>
<button type="button" onClick={() => setNameEditing(false)} className="flex-none text-sm text-text-tertiary-2">
取消
</button>
</div>
) : (
<span className="flex min-w-0 items-center gap-1">
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
{/* Owner-only -- renaming a group is a group-level action, not a per-member one
like the 我在本群的昵称 row further down, so this follows this file's other
owner-gated actions (转让群主/解散群聊) rather than opening it to admins too. */}
{isOwner ? (
<button
type="button"
aria-label="修改群名"
onClick={() => {
setNameDraft(groupQuery.data?.name || '');
setNameEditing(true);
}}
className="flex-none text-text-tertiary-3"
>
<Icon name="edit" className="text-[16px]" />
</button>
) : null}
</span>
)}
<span className="text-xs text-text-tertiary-2">
群号 {topicId} · {memberCount} 人
</span>
</div>
{/* 03-group.dc.html #1a: `qr_code_2` icon, `margin-left:auto` -- a real, mock-confirmed
entry point this row never had. #1e's mobile avatar row instead ends in a generic
`chevron_right` (implying a deeper group-profile screen this app doesn't have), not
this icon -- but shipping a REAL, working QR entry point on mobile too (even if its
glyph doesn't literally match #1e's) beats a chevron that taps through to nowhere. */}
<button type="button" onClick={() => setOpenModal('qrcode')} aria-label="群二维码" className="flex-none text-text-tertiary-3">
<Icon name="qr_code_2" className="text-[20px]" />
</button>
</div>
{groupQuery.data?.description ? <p className="m-0 text-[13px] text-text-secondary">{groupQuery.data.description}</p> : null}
</div>
<div className="flex flex-col px-4 py-3 text-sm md:border-b md:border-border-list-item md:px-5">
{/* Desktop-only header row (#1a): "群成员 N · 查看全部" above the grid. Mobile (#1e) has
no such row -- it puts the same "查看全部成员" affordance as plain centered text
BELOW the grid instead (see the mobile-only button after the grid). */}
<button type="button" onClick={() => setOpenModal('members')} className="hidden w-full items-center justify-between py-1 md:flex">
<span className="text-text-body">群成员 {memberCount}</span>
<span className="flex items-center gap-0.5 text-[13px] text-accent">
查看全部
<Icon name="chevron_right" className="text-[18px]" />
</span>
</button>
<div className="grid grid-cols-5 gap-2 md:mt-2">
{members.slice(0, 9).map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => openUserCard(m.user_id)}
className="relative flex flex-col items-center gap-1"
>
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
desktop's 40px (unconditional here before this fix). */}
<Avatar
id={m.user_id}
name={groupMemberDisplayName(m)}
avatarFileId={m.avatar_file_id}
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
/>
<RoleCorner role={m.role} />
<span className="w-full truncate text-center text-[11px] text-text-secondary">{groupMemberDisplayName(m)}</span>
</button>
))}
{canInvite ? (
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
// plain member list -- only THIS tile's whole purpose is inviting.
<button type="button" onClick={() => setOpenModal('invite')} className="flex flex-col items-center gap-1">
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
<Icon name="add" className="text-[18px]" />
</span>
<span className="text-[11px] text-text-secondary">邀请</span>
</button>
) : null}
{canRemoveAnyone ? (
// Sibling tile to "+邀请" right above -- same dashed-square treatment, mirrored icon.
// A single standalone entry point into the plain member list (not a per-avatar corner
// badge, and not a direct remove action) -- the actual "移出" control already lives on
// each removable row inside that list. Explicit user correction after a first attempt
// put a "-" badge on every removable avatar's own corner: that read as "many separate
// remove buttons" when the intent was one "+"-shaped entry point, mirroring "+邀请".
// Gated on canRemoveAnyone (not bare isAdmin) so the tile doesn't show in a group made
// entirely of owner+admins with nobody actually removable -- a dead end otherwise.
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
<Icon name="remove" className="text-[18px]" />
</span>
<span className="text-[11px] text-text-secondary">移出</span>
</button>
) : null}
</div>
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
查看全部成员 ({memberCount})
</button>
</div>
</div>
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
<button type="button" onClick={() => setOpenModal('announcement')} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">群公告</span>
<span className="flex max-w-[65%] items-center gap-0.5 text-[13px] text-text-tertiary-2">
<span className="truncate">{summaryQuery.data?.announcement?.content || '未设置'}</span>
<Icon name="chevron_right" className="flex-none text-[20px] text-icon-arrow" />
</span>
</button>
<button type="button" onClick={() => setOpenModal('files')} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">群文件</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
<button type="button" onClick={() => onOpenHistory('text')} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">查找聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
{isAdmin ? (
<button type="button" onClick={() => setOpenModal('admin')} className="flex items-center justify-between py-3 text-left md:hidden">
<span className="flex items-center gap-2 text-text-body">
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
群管理
</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
) : null}
{/* Not in #1e's mock at all (see the block comment above) -- kept for parity with desktop,
which does have this row (just grouped with 群管理/清空聊天记录 there instead). */}
{nicknameEditing ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">我在本群的昵称</span>
<input
autoFocus
value={nicknameDraft}
onChange={(e) => setNicknameDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && nicknameMutation.mutate(nicknameDraft)}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => nicknameMutation.mutate(nicknameDraft)} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setNicknameDraft(myProfileQuery.data?.nickname || '');
setNicknameEditing(true);
}}
className="flex items-center justify-between py-3 text-left"
>
<span className="text-text-body">我在本群的昵称</span>
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
{myProfileQuery.data?.nickname || '未设置'}
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
)}
</div>
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
<div className="flex items-center justify-between py-3">
<span className="text-text-body">置顶聊天</span>
<Toggle
checked={!!settingsQuery.data?.pinned}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
/>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-text-body">消息免打扰</span>
<Toggle
checked={!!settingsQuery.data?.mute_notifications}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
/>
</div>
</div>
<div className="flex flex-col bg-white px-4 pb-2 text-sm md:px-5">
{/* Desktop groups 群管理 here (#1a); mobile already rendered it in the card above (#1e),
so this copy is md-only to avoid showing the row twice. */}
{isAdmin ? (
<button type="button" onClick={() => setOpenModal('admin')} className="hidden items-center justify-between py-3 text-left md:flex">
<span className="flex items-center gap-2 text-text-body">
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
群管理
</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
) : null}
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">清空聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">投诉举报</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
</div>
{/* Desktop (#1a): bordered pill button, centered with margin -- unchanged. */}
<button
type="button"
onClick={startLeaveOrDisband}
disabled={leaveMutation.isPending || disbandMutation.isPending}
className="mx-5 my-4 hidden h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text disabled:opacity-50 md:block"
>
{isOwner ? '解散群聊' : '退出群聊'}
</button>
{/* Mobile (#1e): its own plain full-width white card, no border/radius. */}
<button
type="button"
onClick={startLeaveOrDisband}
disabled={leaveMutation.isPending || disbandMutation.isPending}
className="flex-none bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50 md:hidden"
>
{isOwner ? '解散群聊' : '退出群聊'}
</button>
</div>
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
{openModal === 'members' || openModal === 'invite' ? (
<GroupMembersModal
topicId={topicId}
canInvite={canInvite}
myUserId={myUserId}
myRole={myRole || 'member'}
initialView={openModal === 'invite' ? 'invite' : 'members'}
onClose={() => setOpenModal(null)}
/>
) : null}
{openModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
) : null}
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} canUpload={canUpload} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
</aside>
);
}
-317
View File
@@ -1,317 +0,0 @@
import { useRef, useState } from 'react';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { addGroupFile, deleteGroupFile, getAllGroupMembers, getGroupFiles, type GroupFile } from '@/lib/api/groups';
import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload';
import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { useSessionStore } from '@/stores/session';
function isPdf(f: GroupFile): boolean {
return f.mime_type === 'application/pdf' || f.file_name.toLowerCase().endsWith('.pdf');
}
/** Authenticated fetch-to-blob, shared by download and "open in new tab" -- both need the same
* custom auth header a plain `<a href>`/`window.open` can't carry (see AttachmentPreview.tsx's own
* comment on this constraint), they just do different things with the resulting blob URL. */
async function fetchFileBlobUrl(fileId: string): Promise<string> {
const res = await fetch(fileDownloadUrl(`/v0/file/s/${fileId}`), { headers: fileAuthHeaders() });
// Group files are GC'd as "unreferenced" within hours (known backend issue) -- without this
// check, a 404's JSON error body gets treated as if it were the real file.
if (!res.ok) throw new Error();
return URL.createObjectURL(await res.blob());
}
interface GroupFilesModalProps {
topicId: string;
/** Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupFiles): delete is
* allowed for the uploader OR a group admin/owner, not the uploader alone -- without this, an
* owner/admin has no way to remove another member's upload from this UI even though the server
* would accept the request. */
isAdmin: boolean;
/** `governance.member_upload_files` (所有成员/仅管理员), computed by the caller alongside
* `isAdmin` the exact same way `GroupDetailColumn`'s own `canInvite` already is -- without this,
* the toggle in GroupAdminModal had zero effect on this button, the same class of bug
* `canInvite` was already fixed for. */
canUpload: boolean;
onClose: () => void;
}
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
/** `POST .../files`'s `file_type` field has no documented enum anywhere in the protocol doc -- ported
* from the old app's own real, working `group-management-view.jsx` (mime-prefix first, a document-
* extension allowlist as fallback), which itself notes this field being omitted entirely used to
* make every group file show a generic icon regardless of real type -- the read-side icon-branching
* bug found in this rewrite's own review pass had the same root cause. */
function fileTypeFromMime(mime: string, name: string): string {
const prefix = mime.split('/')[0];
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
const ext = /\.([a-zA-Z0-9]+)$/.exec(name)?.[1]?.toLowerCase();
if (mime === 'application/pdf' || (ext && DOCUMENT_EXT.includes(ext))) return 'document';
return 'file';
}
/**
* README §4.9 (480px). Files are registered via the message-send path (attachments.ts) or a direct
* upload here -- this view lists/uploads/downloads/deletes the group's file INDEX (lib/api/groups.ts's
* getGroupFiles/addGroupFile/deleteGroupFile). Upload reuses the same native `/v0/file/u/` channel as
* chat attachments/avatars (uploadFile), then registers the result via addGroupFile -- which needs
* more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported
* from the old app's real, working upload flow rather than guessed from the doc.
*/
export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupFilesModalProps) {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
const [downloadError, setDownloadError] = useState('');
const [actionError, setActionError] = useState('');
const [uploading, setUploading] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
useEscapeKey(onClose);
const filesQuery = useInfiniteQuery({
queryKey: ['group-files', topicId, debouncedQuery],
queryFn: ({ pageParam }) => getGroupFiles(topicId, debouncedQuery, '', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const files = filesQuery.data?.pages.flatMap((p) => p.items) ?? [];
// `GroupFile.uploader_id` is a bare user id with no display name attached (confirmed live) --
// mock `03-group.dc.html #1h`'s rows all show "size · uploader · time", not just "size · time".
// Same lookup-by-id-against-the-member-list pattern GroupAnnouncementModal already uses for its
// own author row.
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const uploaderName = (uploaderId: string) => membersQuery.data?.find((m) => m.user_id === uploaderId)?.display_name || uploaderId;
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploading(true);
try {
// Confirmed live: the upload response never echoes back name/size/mime (see upload.ts's own
// UploadedFile comment) -- read them off the original File object instead, exactly like the
// chat-attachment upload path (lib/tinode/attachments.ts) already correctly does.
const uploaded = await uploadFile(file, topicId);
await addGroupFile(topicId, {
file_id: uploaded.file_id,
file_name: file.name,
mime_type: file.type,
file_type: fileTypeFromMime(file.type, file.name),
file_size: file.size,
});
queryClient.invalidateQueries({ queryKey: ['group-files', topicId] });
} catch (err) {
setActionError(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
const deleteMutation = useMutation({
mutationFn: (groupFileId: string) => deleteGroupFile(topicId, groupFileId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['group-files', topicId] }),
onError: (err) => setActionError(err instanceof Error ? err.message : '删除失败'),
});
const downloadFile = (f: GroupFile) =>
fetchFileBlobUrl(f.file_id)
.then((url) => {
const link = document.createElement('a');
link.href = url;
link.download = f.file_name;
link.click();
// Keep the object URL alive long enough for the browser to start the download.
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
})
.catch(() => setDownloadError('文件下载失败,可能已过期'));
const openFileInNewTab = (f: GroupFile) =>
fetchFileBlobUrl(f.file_id)
.then((url) => window.open(url, '_blank', 'noopener,noreferrer'))
.catch(() => setDownloadError('文件打开失败,可能已过期'));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[520px] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
<div className="flex items-center gap-1">
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
the neutral gray this used to be. Hidden (not just disabled) when governance says
only admins may upload and this viewer isn't one -- there's no "ask an admin" action
a plain member could take from here, so a visible-but-blocked button would only
invite a confusing click. */}
{canUpload ? (
<label
className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`}
>
<Icon name={uploading ? 'hourglass_empty' : 'upload'} className="text-[18px]" />
上传
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
</label>
) : null}
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</div>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索群文件"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{filesQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : filesQuery.isError ? (
<div className="py-10 text-center text-sm text-danger-text">加载失败,请重试</div>
) : files.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群文件</div>
) : (
<>
{files.map((f) => {
const style = fileTypeStyle(f.file_name, f.mime_type);
// No `permissions` field exists on the real response (see groups.ts's own comment),
// but the real delete authorization (confirmed against im_be/server/im_http.go's
// serveIMGroupFiles) is uploader OR group admin/owner -- `isAdmin` is passed down from
// GroupDetailColumn, which already resolves the caller's real role for this same group.
const canDelete = isAdmin || (!!myUserId && f.uploader_id === myUserId);
const pdf = isPdf(f);
// 03-group.dc.html #1h draws three DIFFERENT trailing icons across its 3 example
// rows: a plain `download` for a generic .docx, `open_in_new` for a .pdf (browsers
// can render PDFs inline, so "open" is more useful than "save"), and `more_horiz` for
// the row whose uploader is "我" (me) -- i.e. the icon is a function of BOTH file type
// AND whether this is my own upload, not either one alone.
return (
<div key={f.group_file_id} className="relative flex items-center gap-1 rounded-lg hover:bg-surface-rail">
<button
type="button"
onClick={() => (pdf ? openFileInNewTab(f) : downloadFile(f))}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-3 py-2 text-left"
>
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{f.file_name}</span>
<span className="truncate text-xs text-text-tertiary-2">
{formatFileSize(f.file_size)} · {uploaderName(f.uploader_id)} · {formatEventTimestamp(new Date(f.created_at))}
</span>
</span>
{canDelete ? null : <Icon name={pdf ? 'open_in_new' : 'download'} className="flex-none text-[20px] text-text-placeholder-2" />}
</button>
{canDelete ? (
<FileMenu
isPdf={pdf}
open={openMenuId === f.group_file_id}
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
// Functional update, not a plain `setOpenMenuId(null)` -- this fires from
// useClickOutside's window-level listener, which runs AFTER another row's own
// onToggle already ran in the same click (React's synthetic onToggle always
// precedes a window listener in the bubble order). An unconditional null would
// clobber that other row's freshly-opened state; only clear if this row is
// still the one actually open.
onClose={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : cur))}
onDownload={() => downloadFile(f)}
onOpen={() => openFileInNewTab(f)}
onDelete={async () => {
setOpenMenuId(null);
if (await requestConfirm({ message: `确定删除文件"${f.file_name}"吗?`, danger: true })) {
deleteMutation.mutate(f.group_file_id);
}
}}
deleting={deleteMutation.isPending}
/>
) : null}
</div>
);
})}
{filesQuery.hasNextPage ? (
<button
type="button"
onClick={() => filesQuery.fetchNextPage()}
disabled={filesQuery.isFetchingNextPage}
className="w-full py-3 text-center text-sm text-accent disabled:opacity-60"
>
{filesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</>
)}
</div>
<Toast message={downloadError} variant="error" onDone={() => setDownloadError('')} />
<Toast message={actionError} variant="error" onDone={() => setActionError('')} />
</div>
</div>
);
}
/** The mock's `more_horiz` trailing icon for a file I uploaded, opening a small anchored menu with
* the actions the plain trailing icon otherwise carries alone (预览/下载) plus 删除 -- no mock draws
* the open menu itself (only the closed `more_horiz` icon), so the menu's own layout borrows the
* small-popover convention already used for MessageContextMenu.tsx (white card, shadow, icon+label
* rows, a divider before the destructive action). */
function FileMenu({
isPdf,
open,
onToggle,
onClose,
onDownload,
onOpen,
onDelete,
deleting,
}: {
isPdf: boolean;
open: boolean;
onToggle: () => void;
onClose: () => void;
onDownload: () => void;
onOpen: () => void;
onDelete: () => void;
deleting: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, onClose, open);
return (
<div ref={ref} className="relative flex-none">
<button type="button" aria-label="更多操作" onClick={onToggle} className="grid h-8 w-8 place-items-center rounded-md text-text-tertiary-3 hover:bg-white">
<Icon name="more_horiz" className="text-[20px]" />
</button>
{open ? (
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg bg-white py-1 shadow-modal">
{isPdf ? (
<button type="button" onClick={onOpen} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
<Icon name="open_in_new" className="text-[18px] text-text-tertiary-2" />
预览
</button>
) : null}
<button type="button" onClick={onDownload} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
<Icon name="download" className="text-[18px] text-text-tertiary-2" />
下载
</button>
<div className="my-1 border-t border-border-list-item" />
<button type="button" onClick={onDelete} disabled={deleting} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger-text disabled:opacity-50">
<Icon name="delete_outline" className="text-[18px]" />
删除
</button>
</div>
) : null}
</div>
);
}
@@ -1,245 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { requestConfirm } from '@/stores/confirmDialog';
import { openUserCard } from '@/stores/userCard';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
// MessageRoleTag exactly (this file previously used filled `bg-*-bg` pills instead).
const ROLE_CLASS: Record<GroupRole, string> = { owner: 'border border-warn text-warn', admin: 'border border-accent text-accent', member: '' };
function RoleTag({ role }: { role: GroupRole }) {
if (role === 'member') return null;
return <span className={`flex-none rounded-[3px] px-1 text-[10px] leading-[14px] ${ROLE_CLASS[role]}`}>{ROLE_LABEL[role]}</span>;
}
interface GroupMembersModalProps {
topicId: string;
/** Whether the current viewer can invite -- owner/admin, OR any member when the group's
* `governance.member_invite` toggle (GroupAdminModal's "邀请入群权限") is set to "所有成员".
* Computed by the caller (GroupDetailColumn already has both `my_role` and `governance` in scope
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
* what silently made that governance toggle a no-op before this was wired through. */
canInvite: boolean;
/** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember, lib/api/groups.ts),
* mirroring the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */
myUserId: string | null;
myRole: GroupRole;
/** "invite" opens straight into the invite-candidates view, skipping the member list entirely --
* the dedicated "邀请" entry point (GroupDetailColumn's dashed-border tile) used to route through
* here too but land on the member list regardless, requiring a SECOND click on an in-modal "邀请"
* button to actually reach this view (a real, reported UX complaint: "点击邀请那个按钮弹出的并不是
* 直接到邀请,而是显示的成员列表,还要再次点击邀请"). Defaults to "members" for every OTHER caller
* (查看全部成员 etc.), which should still land on the member list as before. */
initialView?: 'members' | 'invite';
onClose: () => void;
}
/** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by
* `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to
* an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no
* documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added
* `DELETE /groups/{topic}/members/{user_id}` (see removeGroupMember's own comment), so this is no
* longer a backend gap. */
/** Per-candidate outcome once invited THIS session -- keyed by user_id, separate from `alreadyMember`
* (a person can be successfully invited without becoming a member yet -- see the "pending"/
* "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations
* (confirmed: grepped the protocol doc), so this can only ever reflect invites sent from THIS modal
* session, not ones sent earlier and never followed up on -- reopening the modal loses this. */
type InviteOutcome = GroupInvitationResult['status'];
const OUTCOME_LABEL: Record<InviteOutcome, string> = {
joined: '已加入',
already_member: '已在群内',
pending: '邀请已发送',
pending_approval: '待对方确认',
failed: '邀请失败',
};
export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) {
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [inviting, setInviting] = useState(initialView === 'invite');
const [error, setError] = useState('');
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
// Per-row "移出中" tracking -- `removeMutation` is one shared mutation instance, so its own
// `.variables`/`.isPending` only ever reflects the MOST RECENT call. Removing member A then quickly
// also removing member B would make A's button-disabled/"移出中…" state resolve back to normal the
// moment B's click fires, even though A's own request may still be in flight.
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
useEscapeKey(onClose);
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const members = membersQuery.data || [];
const filtered = members.filter((m) => !query.trim() || groupMemberDisplayName(m).toLowerCase().includes(query.trim().toLowerCase()));
const friendsQuery = useQuery({
queryKey: ['friends'],
queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)),
enabled: inviting,
});
// README §4.7: friends already in the group stay VISIBLE, dimmed + labeled + unselectable -- not
// silently filtered out (which used to make the list look shorter than the user's real friend
// list with no explanation why some names were missing).
const inviteCandidates = (friendsQuery.data || []).map((f) => ({ ...f, alreadyMember: members.some((m) => m.user_id === f.user_id) }));
const inviteMutation = useMutation({
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
onSuccess: (data, userId) => {
// A real, previously-missing gap this fixes: the old version only ever refetched the member
// list and left it at that -- for the common "joined immediately" case that's a silent success
// with no feedback beyond the candidate eventually re-rendering as "已在群内" on its own, and
// for "pending"/"pending_approval" (requires the target's own action, or the group's/their own
// approval setting) there was NO feedback at all -- the row just sat there looking un-invited,
// inviting a confused repeat click. `items[0]` because this call always invites exactly one
// user_id at a time.
const outcome = data.items[0]?.status;
if (outcome) setOutcomeByUser((prev) => ({ ...prev, [userId]: outcome }));
if (outcome === 'failed' && data.items[0]?.reason) setError(data.items[0].reason);
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
},
onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'),
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupMember(topicId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
},
onError: (err) => setError(err instanceof Error ? err.message : '移出失败'),
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">{inviting ? '邀请成员' : `群成员 ${members.length}`}</strong>
<div className="flex items-center gap-2">
{!inviting && canInvite ? (
<button type="button" onClick={() => setInviting(true)} className="text-sm text-accent">
邀请
</button>
) : null}
<button type="button" onClick={inviting ? () => setInviting(false) : onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name={inviting ? 'arrow_back' : 'close'} />
</button>
</div>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={inviting ? '搜索联系人' : '搜索群成员'}
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{inviting ? (
inviteCandidates
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
.map((f) => {
const label = f.remark || f.display_name || f.user_id;
// `alreadyMember` (a real member, known up front) and a THIS-SESSION invite outcome
// are two different reasons a candidate can be non-actionable -- alreadyMember wins if
// both are somehow true (the member-list refetch above should already have cleared the
// "joined" outcome to alreadyMember=true by then anyway).
const outcome = f.alreadyMember ? 'already_member' : outcomeByUser[f.user_id];
// "failed" deliberately does NOT count as settled -- could be a transient network blip,
// and there's no harm in letting the same click retry (a genuinely permanent failure,
// e.g. the target blocked invites, just fails again harmlessly). Every other outcome is
// a real, done state with nothing left to retry.
const settled = !!outcome && outcome !== 'failed';
return (
<button
key={f.user_id}
type="button"
disabled={inviteMutation.isPending || settled}
onClick={() => inviteMutation.mutate(f.user_id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
settled ? 'opacity-50' : ''
}`}
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
{avatarLetter(label)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
{outcome ? (
<span className={`flex-none text-xs ${outcome === 'failed' ? 'text-danger-text' : 'text-text-tertiary-3'}`}>
{OUTCOME_LABEL[outcome]}
</span>
) : (
<Icon name="person_add" className="text-[18px] text-accent" />
)}
</button>
);
})
) : membersQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : (
filtered.map((m) => (
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(groupMemberDisplayName(m))}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{groupMemberDisplayName(m)}</span>
</button>
<RoleTag role={m.role} />
{canRemoveMember(myUserId, myRole, m) ? (
<button
type="button"
disabled={removingIds.has(m.user_id)}
onClick={async () => {
const label = groupMemberDisplayName(m);
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
setRemovingIds((prev) => new Set(prev).add(m.user_id));
removeMutation.mutate(m.user_id, {
onSettled: () =>
setRemovingIds((prev) => {
const next = new Set(prev);
next.delete(m.user_id);
return next;
}),
});
}
}}
className="flex flex-none cursor-pointer items-center gap-0.5 text-xs text-danger-text hover:underline disabled:pointer-events-none disabled:cursor-default disabled:opacity-60 disabled:hover:no-underline"
>
{removingIds.has(m.user_id) ? (
'移出中…'
) : (
<>
<Icon name="remove_circle" className="text-sm" />
移出
</>
)}
</button>
) : null}
</div>
))
)}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}
@@ -1,137 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { fetchAllPages } from '@/lib/api/client';
import { acceptGroupJoinRequest, getAllGroupMembers, getGroupJoinRequests, rejectGroupJoinRequest, type GroupJoinRequest } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { formatEventTimestamp } from '@/lib/messages/time';
interface JoinRequestsModalProps {
topicId: string;
onClose: () => void;
}
/**
* README §4.6. Reject sends `{version}` ONLY -- no `reason` field, even though the protocol doc's
* own example shows one (see lib/api/groups.ts's rejectGroupJoinRequest comment: the old app
* confirmed live that the real server 400s on an unrecognized `reason` key).
*/
export function JoinRequestsModal({ topicId, onClose }: JoinRequestsModalProps) {
const queryClient = useQueryClient();
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
useEscapeKey(onClose);
const query = useQuery({
queryKey: ['group-join-requests', topicId],
// `'pending'`-only used to mean a request resolved before this session (or by another admin)
// just never appeared here at all -- the render below already has a real "已同意/已拒绝" branch
// keyed off the server's own `req.status`, it just never got fed anything but pending requests to
// apply that branch to. README §4.6's own mock shows an already-resolved row persisting in the
// list, matching `'all'`, not `'pending'`.
queryFn: () => fetchAllPages<GroupJoinRequest>((cursor) => getGroupJoinRequests(topicId, 'all', cursor)),
});
// The applicant themself has no display name anywhere on the real response (see groups.ts's own
// GroupJoinRequest comment) and isn't a member yet, so there's no list to resolve THEIR name from
// either -- shown as a bare id, honestly, rather than guessed. The INVITER (mock: "王工 邀请") IS
// already a member by definition, so that name can be resolved the same way GroupFilesModal's
// uploader name and GroupAnnouncementModal's author name already do.
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const memberName = (userId: string) => membersQuery.data?.find((m) => m.user_id === userId)?.display_name || userId;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
};
const acceptMutation = useMutation({
mutationFn: (req: GroupJoinRequest) => acceptGroupJoinRequest(topicId, req.request_id, req.version),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
invalidate();
},
});
const rejectMutation = useMutation({
mutationFn: (req: GroupJoinRequest) => rejectGroupJoinRequest(topicId, req.request_id, req.version),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
// Unlike accept, nothing here changes group membership -- but `group-summary`'s
// `pending_join_request_count` badge (read by GroupAdminModal's "进群申请" row) does need to
// drop by one, and this previously invalidated nothing at all, leaving that badge stuck high.
invalidate();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">进群申请</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !query.data?.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无进群申请</div>
) : (
query.data.map((req) => {
const status = handled[req.request_id];
return (
<div key={req.request_id} className="flex items-center gap-3 px-3 py-2.5">
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.user_id)}`}>
{avatarLetter(req.user_id)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{req.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
{/* mock (#1c): "{邀请人} 邀请" when invited, else a source label -- NOT
`source==='qr' ? ... : 邀请`, which used to show "邀请" for every
non-qr source including a plain self-initiated search request that
nobody actually invited (confirmed live: `source` really does take a
`"search"` value distinct from an invite). `inviter_id` is the real
signal for "was this an invite", not `source`. */}
{req.inviter_id ? `${memberName(req.inviter_id)} 邀请` : req.source === 'qr' ? '扫码申请' : '申请加入'}
{req.message ? ` · "${req.message}"` : ''} · {formatEventTimestamp(new Date(req.created_at))}
</span>
</span>
{status || req.status !== 'pending' ? (
<span className="flex-none text-xs text-text-tertiary-3">
{status === 'accepted' || req.status === 'accepted' ? '已同意' : '已拒绝'}
</span>
) : (
<span className="flex flex-none items-center gap-2">
<button
type="button"
onClick={() => rejectMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
拒绝
</button>
<button
type="button"
onClick={() => acceptMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
同意
</button>
</span>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
export const privateChatOptions: { value: 'all' | 'friends'; label: string }[] = [
{ value: 'all', label: '全员' },
{ value: 'friends', label: '好友添加' },
];
export function privateChatOption(enabled: boolean): 'all' | 'friends' {
return enabled ? 'all' : 'friends';
}
export function canEditGroupAvatar(permission: boolean | undefined, role: 'owner' | 'admin' | 'member' | undefined): boolean {
return permission ?? (role === 'owner' || role === 'admin');
}
export function groupAvatarError(file: Pick<File, 'type' | 'size'>): string | null {
if (!file.type.startsWith('image/')) return '请选择图片文件';
return file.size > 5 * 1024 * 1024 ? '图片大小不能超过 5MB' : null;
}
@@ -1,362 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
interface ImageData {
val?: string;
ref?: string;
mime?: string;
width?: number;
height?: number;
/** Drafty.insertImage's own `filename` field -- present on every image this WEB client sent
* (see sendImage in lib/tinode/attachments.ts). */
filename?: string;
/** mobileAttachmentContentOf's `name` -- the equivalent field on an image sent from the iOS/Android
* app, which flows through a completely different (non-Drafty) flat shape than the web client's
* own `IM` entity above; MessageBubble passes that content straight through as this same ImageData
* shape, so both need a name field here even though the two clients don't call it the same thing. */
name?: string;
}
/** Best-effort filename for the "保存图片" download below -- prefers whatever name the sender's own
* client attached (see ImageData's own field comments), falling back to a generic name derived from
* the mime type for the inline-base64/mobile-relay cases where neither field is set. */
function imageDownloadName(data: ImageData): string {
if (data.filename) return data.filename;
if (data.name) return data.name;
const ext = data.mime?.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
return `image.${ext}`;
}
const LIGHTBOX_ZOOM_MIN = 1;
const LIGHTBOX_ZOOM_MAX = 4;
const LIGHTBOX_ZOOM_STEP = 0.5;
/** Full-screen "查看大图" overlay opened by tapping a chat image -- `src` is the SAME blob/data URI
* ImageAttachment already resolved (real full-resolution image; the 260x170 thumbnail is only a CSS
* crop of it, not a separately-fetched smaller asset), so opening this needs no extra network work.
* Zoom is CSS transform scale/translate on the `<img>` itself, not a native browser zoom -- wheel
* and the +/- buttons zoom around the center; dragging only does anything once zoomed in, otherwise
* the image has nowhere to pan to and a drag gesture would just fight the backdrop's click-to-close. */
function ImageLightbox({ src, filename, onClose }: { src: string; filename: string; onClose: () => void }) {
const [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN);
const [pan, setPan] = useState({ x: 0, y: 0 });
const imgRef = useRef<HTMLImageElement>(null);
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
useEscapeKey(onClose);
const clampZoom = (z: number) => Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, z));
const applyZoom = (next: number) => {
const clamped = clampZoom(next);
setZoom(clamped);
if (clamped === LIGHTBOX_ZOOM_MIN) setPan({ x: 0, y: 0 });
};
const zoomIn = () => applyZoom(zoom + LIGHTBOX_ZOOM_STEP);
const zoomOut = () => applyZoom(zoom - LIGHTBOX_ZOOM_STEP);
const toggleZoom = () => applyZoom(zoom > LIGHTBOX_ZOOM_MIN ? LIGHTBOX_ZOOM_MIN : LIGHTBOX_ZOOM_MAX);
// Native listener with `passive:false`, not JSX `onWheel` -- React's synthetic wheel handler isn't
// guaranteed non-passive across versions/browsers, and a passive listener's preventDefault() is
// silently ignored, which would leave the page scrolling underneath the lightbox while "zooming".
useEffect(() => {
const el = imgRef.current;
if (!el) return undefined;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
applyZoom(zoom + (e.deltaY < 0 ? LIGHTBOX_ZOOM_STEP : -LIGHTBOX_ZOOM_STEP));
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-binds each render so onWheel always
// closes over the current `zoom`; the listener itself is cheap to re-add and this avoids a stale
// closure that would zoom from an outdated level.
}, [zoom]);
const onMouseDown = (e: React.MouseEvent) => {
if (zoom <= LIGHTBOX_ZOOM_MIN) return;
e.stopPropagation();
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
};
const onMouseMove = (e: React.MouseEvent) => {
if (!dragRef.current) return;
setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.startX), y: dragRef.current.panY + (e.clientY - dragRef.current.startY) });
};
const endDrag = () => {
dragRef.current = null;
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80" onClick={onClose}>
<div className="absolute right-6 top-6 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={zoomOut}
disabled={zoom <= LIGHTBOX_ZOOM_MIN}
aria-label="缩小"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
>
<Icon name="zoom_out" className="text-[20px]" />
</button>
<span className="min-w-[3.5em] text-center text-sm text-white">{Math.round(zoom * 100)}%</span>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= LIGHTBOX_ZOOM_MAX}
aria-label="放大"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
>
<Icon name="zoom_in" className="text-[20px]" />
</button>
{/* Real anchor + `download`, same technique FileAttachment already relies on below -- works
for both inline `data:` URIs (small images) and the `blob:` URL useAuthedBlobUrl fetches
for larger ones, triggering a normal save-to-disk with no extra round trip. User report
(老板, 2026-09-29): "图片也没有保存功能" -- right-click "另存为" technically already works on
an `<img>`, but wasn't discoverable enough; this puts an explicit, visible affordance here,
same place every mainstream IM's own "查看大图" view puts it. */}
<a
href={src}
download={filename}
aria-label="保存图片"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white"
>
<Icon name="download" className="text-[20px]" />
</a>
<button type="button" onClick={onClose} aria-label="关闭" className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white">
<Icon name="close" className="text-[20px]" />
</button>
</div>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag
target inside an already-dismissible dialog; alt="" is correct here, same reasoning as
ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */}
<img
ref={imgRef}
src={src}
alt=""
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => {
e.stopPropagation();
toggleZoom();
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`}
/>
</div>
);
}
/** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same
* reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same
* thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so
* entering select mode doesn't visually change every image bubble in the list. */
export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
const [viewing, setViewing] = useState(false);
if (!src) {
return (
<div className="grid h-[170px] w-[260px] place-items-center rounded-lg bg-surface-rail">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[28px] text-text-tertiary-3" />
</div>
);
}
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
const thumb = <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
if (locked) return thumb;
return (
<>
<button type="button" onClick={() => setViewing(true)} className="block">
{thumb}
</button>
{viewing ? <ImageLightbox src={src} filename={imageDownloadName(data)} onClose={() => setViewing(false)} /> : null}
</>
);
}
interface FileData {
val?: string;
ref?: string;
mime?: string;
name?: string;
size?: number;
}
/** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row,
* not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in
* new tab" aren't specially guarded against, matching this codebase's existing level of rigor for
* other locked-during-selectMode controls). */
export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) {
const style = fileTypeStyle(data.name, data.mime);
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
const href = inline || remote.url || undefined;
return (
<a
href={href}
download={data.name}
onClick={(e) => {
if (locked) {
e.preventDefault();
} else if (remote.error) {
e.preventDefault();
remote.retry();
} else if (!href) {
e.preventDefault();
}
}}
className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble"
>
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
<span className={`text-xs ${remote.error ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '}
{remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'}
</span>
</span>
<Icon
name={remote.error ? 'error_outline' : 'download'}
className={`ml-auto flex-none text-[20px] ${remote.error ? 'text-danger-text' : 'text-text-placeholder-2'}`}
/>
</a>
);
}
interface VideoData {
val?: string;
ref?: string;
mime?: string;
preview?: string;
previewRef?: string;
duration?: number;
}
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
*
* `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
* since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
// Mobile-sent videos (see mobileAttachmentContentOf) have no inline base64 poster at all -- their
// thumbnail is a separately-hosted file (`previewRef`, the same `/v0/file/s/{id}` shape as the
// video body itself), not the base64 `preview` field this component was built around for the web
// client's own Drafty `VD` entities (confirmed live, favorites' identical shape). Only fetched when
// there's no inline poster, same short-circuit `remote` above already uses for the video body.
const posterRemote = useAuthedBlobUrl(posterInline ? undefined : data.previewRef);
const posterUrl = posterInline || posterRemote.url;
const videoSrc = videoInline || remote.url;
if (playing && videoSrc) {
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
}
return (
<button
type="button"
onClick={() => {
if (locked) return;
setPlaying(true);
}}
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
{posterUrl ? (
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={posterRemote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
</div>
)}
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
{typeof data.duration === 'number' ? (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</button>
);
}
interface AudioData {
val?: string;
ref?: string;
mime?: string;
duration?: number;
}
/** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
* so the click still bubbles to the row toggle instead of being swallowed by the native button.
* Ongoing playback started BEFORE selectMode was entered is left alone -- this only blocks a NEW
* toggle action. */
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
const [playing, setPlaying] = useState(false);
const [playError, setPlayError] = useState(false);
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
useEffect(() => {
if (!audioEl) return undefined;
const onEnded = () => setPlaying(false);
audioEl.addEventListener('ended', onEnded);
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
// playing leaves it playing in the background with no control left on screen to stop it.
return () => {
audioEl.removeEventListener('ended', onEnded);
audioEl.pause();
};
}, [audioEl]);
const toggle = () => {
if (locked) return;
if (!audioEl || !src) return;
if (playing) {
audioEl.pause();
setPlaying(false);
return;
}
if (audioEl.src !== src) audioEl.src = src;
setPlayError(false);
audioEl
.play()
.then(() => setPlaying(true))
.catch(() => setPlayError(true));
};
return (
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
<Icon
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
/>
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
</span>
</button>
);
}
@@ -1,219 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { friendAvatarLookup } from '@/features/messages/chat-list';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { getGroups } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile';
import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* README §2.1's always-pinned utility row. Real, confirmed backend fact (docs/IM-前后端对接
* 文档.md:909): topic name is fixed ("sys"), but 系统消息's unread comes from a separate
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
* a route outside MessagesLayout entirely).
*
* 文件助手 (a pinned entry that redirected into a plain ConversationView on the `slf` topic --
* README §9.2's own dedicated 3-column file browser has no backing REST API at all, confirmed via a
* research pass) was deliberately removed from here: the user found it confusing -- it's really
* just a chat thread with yourself wearing a folder icon, and DetailColumn's friend-only actions
* (拉黑/举报/删除好友) made no sense pointed at yourself. Hidden across every entry point (NavRail,
* here, ContactsListColumn, SettingsLayout's mobile shortcuts) rather than half-built into a real
* file browser the backend can't support yet. `slf` itself is untouched -- if it ever has real
* message history it still surfaces as an perfectly ordinary contact row, sorted like any other.
*/
const PINNED_ENTRIES = [
{
key: 'system_messages' as const,
badgeKey: 'system_unread_count' as const,
label: '系统消息',
icon: 'notifications',
route: '/messages/system',
colorClass: 'bg-warn-bg text-warn',
},
];
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
* descending within each group. The pinned flag is read straight off the SDK's own ambient
* `contact.private.pinned` (confirmed present in tinode-sdk.d.ts's `TopicDescPrivate`) rather than
* a separate REST fetch per topic -- DetailColumn's "置顶聊天" toggle already writes through the
* conversation-settings REST endpoint, and the server mirrors that same flag back onto the topic's
* Tinode-level `private` field, so this is free (no extra request) instead of N per-topic fetches
* just to render the list. */
export function ChatListColumn() {
const { topicId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const myUserId = useSessionStore((s) => s.myUserId);
// `slf` (chat with yourself) never has its own `.public.fn` -- shares AppShell's `['me']` query
// key so this is a cache read, not an extra request, in the common case that already mounted it.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
const tinode = useTinode();
const contacts = useChatList();
const bootstrap = useBootstrap();
// Shared with GroupListView's own `['groups','all']` query (same key) -- React Query dedupes the
// two rather than this list issuing its own redundant fetch, and it's one bulk paginated call
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
const avatarByGroupTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.avatar_file_id]));
// Same real gap as the conversation screen's header/message-row avatars (see ConversationView.tsx):
// a P2P row's `contact` object comes from the Tinode SDK's own local cache, which was never taught
// to carry the REST-only `avatar_file_id` this app uploads avatars through -- shares ContactsListColumn's
// exact `['friends']` query key, so this is a cache read whenever that page has already been visited.
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const avatarByPeer = friendAvatarLookup(friendsQuery.data);
const [query, setQuery] = useState('');
const [creatingGroup, setCreatingGroup] = useState(false);
const sorted = [...contacts].sort((a, b) => {
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
});
const trimmedQuery = query.trim();
const filtered = trimmedQuery
? sorted.filter((c) =>
(c.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : c.public?.fn || c.topic || '')
.toLowerCase()
.includes(trimmedQuery.toLowerCase()),
)
: sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
{/* README §5.4's 添加好友 entry point is Phase 4 (contacts), not built yet -- this only
opens 创建群聊 (§4.7) for now, the one thing this icon can actually do today. */}
<button type="button" onClick={() => setCreatingGroup(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<Icon name="add_comment" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索会话、联系人、消息"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
const active = location.pathname === entry.route;
return (
<Link
key={entry.key}
to={entry.route}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-2xl" />
</span>
<span className="flex min-w-0 flex-col gap-1">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
{unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{unread}
</b>
) : null}
</span>
</Link>
);
})}
{filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />
<span className="text-sm">{trimmedQuery ? '未找到相关会话' : '暂无会话'}</span>
</div>
) : (
filtered.map((contact) => {
const name = contact.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType();
// `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group
// the single-group `GET /groups/{topic}` correctly reports as several members -- the same
// dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed
// live to surface as a full zero rather than a smaller mismatch. No real group has zero
// members (the owner alone is one), so the `> 0` check below treats an exact zero as
// "don't trust this" and hides the pill instead of showing a number already proven wrong.
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications;
const avatarFileId =
contact.topic === 'slf' ? meQuery.data?.profile.avatar_file_id : isGroup ? avatarByGroupTopic.get(contact.topic) : avatarByPeer.get(contact.topic);
return (
<Link
key={contact.topic}
to={`/messages/${contact.topic}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
<span className="relative block h-11 w-11 flex-none">
<Avatar id={contact.topic || ''} name={name} avatarFileId={avatarFileId} className="h-11 w-11 rounded-lg text-base" />
{contact.online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
) : null}
</span>
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
{typeof memberCount === 'number' && memberCount > 0 ? (
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
) : null}
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{/* Same reference mock: a muted conversation's unread count is replaced by a small
muted-bell glyph, not shown alongside or as a louder red badge -- the whole point
of 消息免打扰 (DetailColumn's own toggle, which writes this same `private` field)
is to stop this row from demanding attention the way an unmuted one does. */}
{muted ? (
contact.unread ? <Icon name="notifications_off" className="text-base text-text-tertiary-4" /> : null
) : contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
) : null}
</span>
</Link>
);
})
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</aside>
);
}
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import {
blockUser,
deleteFriend,
getBlocks,
getConversationSettings,
getFriends,
getUserProfile,
unblockUser,
updateConversationSettings,
updateFriendRemark,
} from '@/lib/api/friends';
import type { MediaType } from '@/lib/api/messages';
import { getMe } from '@/lib/api/profile';
import { useTinode } from '@/lib/tinode/hooks';
import { getP2PPeerUserId } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { useSessionStore } from '@/stores/session';
interface DetailColumnProps {
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
// README §3.4: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
// (18×18 knob) -- same fix as GroupDetailColumn.tsx's identical Toggle copy. The travel
// distance stays 18px at both sizes since track and knob grow by the same 4px.
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
/>
</button>
);
}
export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: DetailColumnProps) {
const navigate = useNavigate();
const tinode = useTinode();
const queryClient = useQueryClient();
const [remarkEditing, setRemarkEditing] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
// saved against the NEW one, and a leftover error toast would keep showing over the new contact
// misattributing the failure. useLayoutEffect (not useEffect) so this resolves before paint --
// this component never remounts on topicId change, so a post-paint effect can let one frame
// render with the previous contact's stale edit box still open.
useLayoutEffect(() => {
setRemarkEditing(false);
setRemarkDraft('');
setErrorToast('');
}, [topicId]);
// `slf` (self-chat, e.g. opened via a chat-list row once it has real history -- there is no
// "文件助手" entry point pointing at it anymore, see ChatListColumn.tsx's PINNED_ENTRIES doc
// comment) isn't a friend and isn't really "someone else" at all -- getUserProfile('slf') has no
// real user to look up, and every friend-only action below (remark/block/report/delete friend)
// is nonsensical pointed at yourself. Gated out entirely rather than left to silently no-op or
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
// to yourself.
const isSelf = topicId === 'slf';
const myUserId = useSessionStore((s) => s.myUserId);
// NOT topicId -- see getP2PPeerUserId's own doc comment. Every lookup below that means "the other
// user" (profile, friends-list match, block, remark) has to go through this, not the raw topic id,
// because the two stop being interchangeable the instant this conversation has any real history.
const peerUserId = isSelf ? topicId : getP2PPeerUserId(tinode.getTopic(topicId), myUserId);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friend = friendsQuery.data?.find((f) => f.user_id === peerUserId);
const profileQuery = useQuery({
queryKey: ['user-profile', peerUserId],
queryFn: () => getUserProfile(peerUserId || ''),
// `!isSelf` alone isn't enough now that peerUserId can genuinely be undefined for a beat (the
// subscriber list not loaded yet) -- without also gating on it, this fires with `undefined` the
// instant isSelf is false, same class of guaranteed-404 bug this whole fix targets, just with
// the literal string "undefined" as the id instead of the p2p topic name.
enabled: !isSelf && !!peerUserId,
});
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
.filter(Boolean)
.join(' · ');
// Same real gap as ChatListColumn/ConversationView's avatars -- this panel's header tile only ever
// drew the letter fallback, even though `profileQuery`/`friendsQuery` right above already carry a
// real `avatar_file_id` (that's how ContactsListColumn/FriendProfileView show it correctly). `friend`
// (the `/friends` list, same source the contacts pages already trust) takes priority since it's the
// one confirmed accurate; `profileQuery` covers a P2P peer who isn't (or is no longer) a friend.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
const avatarFileId = isSelf ? meQuery.data?.profile.avatar_file_id : (friend?.avatar_file_id ?? profileQuery.data?.avatar_file_id);
const settingsQuery = useQuery({
queryKey: ['conversation-settings', topicId],
queryFn: () => getConversationSettings(topicId),
});
const updateSettings = useMutation({
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
onError: (err) => showError(err, '设置失败,请重试'),
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(peerUserId, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
setRemarkEditing(false);
},
onError: (err) => showError(err, '备注保存失败'),
});
const blockMutation = useMutation({
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
// `['blocks']` alone left the composer's "你已将对方加入黑名单/对方已将你加入黑名单" banner (driven
// by a SEPARATE query, `['conversation-context', topicId]` in ConversationView.tsx) showing the
// pre-mutation state until something ELSE happened to refetch it -- live-confirmed: toggling block
// then re-checking the same conversation immediately still showed the old banner/composer state,
// and it only ever caught up after an incidental `refetchOnWindowFocus` (switching tabs and back).
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
queryClient.invalidateQueries({ queryKey: ['conversation-context', topicId] });
},
onError: (err) => showError(err, '设置失败,请重试'),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === peerUserId);
const deleteFriendMutation = useMutation({
mutationFn: () => deleteFriend(peerUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
navigate('/messages');
},
onError: (err) => showError(err, '删除好友失败'),
});
const clearHistory = async () => {
// The old copy here ("此操作仅清除本地记录") was actively misleading -- confirmed by reading
// the real backend handler (im_be's Topic.replyDelMsg): a soft delete (`hardDel=false`, what
// `delMessagesAll(false)` sends) is NOT a browser-local cache wipe a refresh would undo. The
// server records a per-account delete watermark (`t.perUser[asUid].delID`) that persists across
// every device/session on this account and isn't reversible through any normal client action --
// only "does it affect the other party" (no) was actually true about the old wording.
if (
!(await requestConfirm({
message: '确定清空与该联系人的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响对方。',
danger: true,
}))
)
return;
tinode.getTopic(topicId).delMessagesAll(false);
};
const startDeleteFriend = async () => {
if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return;
deleteFriendMutation.mutate();
};
const reportAbuse = () => {
notify('该功能暂未开放,如需投诉举报请联系客服。');
};
return (
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[320px] lg:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold">聊天设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
) : null}
</header>
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
<Avatar id={peerUserId || topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
{profileQuery.data?.signature ? (
<p className="m-0 text-center text-[13px] text-text-secondary">{profileQuery.data.signature}</p>
) : null}
<div className="mt-3 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent"
>
<Icon name="chat_bubble_outline" className="text-[20px]" />
<span className="text-xs text-text-secondary">发消息</span>
</button>
{isSelf ? null : (
<>
<button
type="button"
disabled={profileQuery.data?.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
}}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
>
<Icon name="call" className="text-[20px]" />
<span className="text-xs text-text-secondary">语音</span>
</button>
<button
type="button"
disabled={profileQuery.data?.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
}}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
>
<Icon name="videocam" className="text-[20px]" />
<span className="text-xs text-text-secondary">视频</span>
</button>
</>
)}
</div>
</div>
<div className="border-b border-border-list-item px-5 pb-3 pt-2 text-sm">
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
<span className="text-text-body">查找聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
<div className="mt-1 grid grid-cols-4 gap-2">
{(
[
{ type: 'image' as const, label: '图片', icon: 'image' },
{ type: 'video' as const, label: '视频', icon: 'videocam' },
{ type: 'file' as const, label: '文件', icon: 'description' },
{ type: 'link' as const, label: '链接', icon: 'link' },
] satisfies { type: MediaType; label: string; icon: string }[]
).map((item) => (
<button key={item.type} type="button" onClick={() => onOpenHistory(item.type)} className="flex flex-col items-center gap-1 py-1">
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
<span className="text-xs text-text-secondary">{item.label}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
{isSelf ? null : remarkEditing ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
<input
autoFocus
value={remarkDraft}
onChange={(e) => setRemarkDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft)}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft)} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setRemarkDraft(friend?.remark || '');
setRemarkEditing(true);
}}
className="flex items-center justify-between py-3 text-left"
>
<span className="text-text-body">备注</span>
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
{friend?.remark || '未设置'}
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
)}
<div className="flex items-center justify-between py-3">
<span className="text-text-body">置顶聊天</span>
<Toggle
checked={!!settingsQuery.data?.pinned}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
/>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-text-body">消息免打扰</span>
<Toggle
checked={!!settingsQuery.data?.mute_notifications}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
/>
</div>
{isSelf ? null : (
<div className="flex items-center justify-between py-3">
<span className="text-text-body">加入黑名单</span>
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
</div>
)}
</div>
<div className="flex flex-col px-5 pb-2 text-sm">
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">清空聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
{isSelf ? null : (
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">投诉举报</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
)}
</div>
{isSelf ? null : (
<button
type="button"
onClick={startDeleteFriend}
className="mx-5 my-4 h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text"
>
删除好友
</button>
)}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
</aside>
);
}
-87
View File
@@ -1,87 +0,0 @@
import { useState } from 'react';
// README §3.3: "分类 Tab(最近/表情/手势/物品),12 列网格" -- the design mockup itself has no
// specific emoji asset list, just gray placeholder tiles captioned "use system emoji or a branded
// pack." This is a plain Unicode emoji grid (no custom art), same call the old app made (per its own
// comment: no WeChat/QQ-style sticker assets available, system emoji is the fallback).
const EMOJI_FACES = [
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭', '😡', '🥳', '😱', '🤯', '🥰', '😇', '🙃', '😉',
'😆', '😅', '☺️', '😋', '😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷', '🤒', '🥴', '😪', '🙄',
];
const EMOJI_GESTURES = [
'👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👌', '🤞', '👋', '👊', '✊', '🤛', '🤜', '🤟', '🤘', '👆', '👇', '👈', '👉',
'☝️', '✋', '🤚', '🖐️', '🖖', '🤙', '💅', '🤳', '👐', '🙌', '🤲', '🫶', '🤌', '🫰', '🫵', '👋', '💃', '🕺', '🙋', '🙆',
];
const EMOJI_OBJECTS = [
'❤️', '💔', '💯', '🔥', '✨', '🎉', '🎂', '☕', '🍺', '🌹', '🎁', '🎈', '⭐', '🌈', '☀️', '🌙', '⚡', '💧', '🎵', '🎮',
'📷', '📱', '💻', '📌', '📎', '🔔', '💰', '⏰', '🔑', '💡', '📖', '✏️', '🍎', '🍕', '🍔', '🍜', '🎄', '🎃', '⚽', '🏆',
];
type EmojiTab = 'recent' | 'faces' | 'gestures' | 'objects';
const TABS: { key: EmojiTab; label: string; list: string[] }[] = [
{ key: 'recent', label: '最近', list: [] },
{ key: 'faces', label: '表情', list: EMOJI_FACES },
{ key: 'gestures', label: '手势', list: EMOJI_GESTURES },
{ key: 'objects', label: '物品', list: EMOJI_OBJECTS },
];
interface EmojiPanelProps {
onPick: (emoji: string) => void;
}
export function EmojiPanel({ onPick }: EmojiPanelProps) {
const [tab, setTab] = useState<EmojiTab>('recent');
const recent = (() => {
try {
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
} catch {
return [];
}
})();
const pick = (emoji: string) => {
const next = [emoji, ...recent.filter((e) => e !== emoji)].slice(0, 36);
try {
localStorage.setItem('im-recent-emoji', JSON.stringify(next));
} catch {
/* storage unavailable -- recents just won't persist */
}
onPick(emoji);
};
const list = tab === 'recent' ? recent : (TABS.find((t) => t.key === tab)?.list ?? []);
return (
<div className="w-[380px] rounded-xl border border-border-panel bg-white p-2 shadow-menu">
<div className="mb-1 flex gap-1 px-1">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`rounded-lg px-2.5 py-1 text-xs ${tab === t.key ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-2'}`}
>
{t.label}
</button>
))}
</div>
{list.length === 0 ? (
<div className="py-6 text-center text-xs text-text-tertiary-4">暂无最近使用</div>
) : (
<div className="grid grid-cols-12 gap-1">
{list.map((emoji, i) => (
<button
key={`${emoji}-${i}`}
type="button"
onClick={() => pick(emoji)}
className="grid h-8 w-8 place-items-center rounded-lg text-lg hover:bg-surface-rail"
>
{emoji}
</button>
))}
</div>
)}
</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
import { Icon } from '@/app/Icon';
export function EmptyMain() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
<Icon name="forum" className="!text-5xl" />
<span className="text-sm">选择一个会话开始聊天</span>
</div>
);
}
@@ -1,185 +0,0 @@
import { useMemo, useState } from 'react';
import type { Contact } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
const MAX_COMMENT_LENGTH = 200;
interface ForwardPickerModalProps {
onPick: (targetTopics: string[], comment: string) => void;
onClose: () => void;
}
/**
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
* forward -- all three previously shared this component when it only supported picking ONE target
* and sending immediately with no comment, meaning forwarding to several people required repeating
* the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message
* forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side
* in one batched call -- this component only collects the selection and comment; callers send.
*/
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
const contacts = useChatList();
const tinode = useTinode();
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [comment, setComment] = useState('');
// Both start expanded -- collapsing is for a long list getting in the way of the OTHER section,
// not a default state anyone would want on first open.
const [collapsed, setCollapsed] = useState<{ friends: boolean; groups: boolean }>({ friends: false, groups: false });
useEscapeKey(onClose);
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
// The list used to mix friends and groups in one flat run -- every other list in this app
// (ChatListColumn's pinned/sorted rows, ContactsListColumn) already keeps the two visually
// separate, so this was the odd one out and made a specific group hard to spot among dozens of
// P2P contacts.
const friends = useMemo(
() => filtered.filter((c) => !!c.topic && !tinode.getTopic(c.topic).isGroupType()),
[filtered, tinode],
);
const groups = useMemo(
() => filtered.filter((c) => !!c.topic && tinode.getTopic(c.topic).isGroupType()),
[filtered, tinode],
);
const toggle = (topic: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(topic)) next.delete(topic);
else next.add(topic);
return next;
});
};
// 好友/群聊各自独立的全选 -- 一次转发常常只想覆盖其中一类(比如"发给所有群"但不想带上私聊
// 好友),一个笼统的全选做不到这一点,所以拆成两个独立开关而不是列表整体一个。
const friendTopics = useMemo(() => friends.map((c) => c.topic).filter((t): t is string => !!t), [friends]);
const groupTopics = useMemo(() => groups.map((c) => c.topic).filter((t): t is string => !!t), [groups]);
const allFriendsSelected = friendTopics.length > 0 && friendTopics.every((t) => selected.has(t));
const allGroupsSelected = groupTopics.length > 0 && groupTopics.every((t) => selected.has(t));
const toggleSectionSelectAll = (topics: string[], allSelected: boolean) => {
setSelected((prev) => {
if (allSelected) {
// Only drop the ones the CURRENT search actually shows -- a selection made under an earlier,
// now-cleared query shouldn't be wiped out by un-selecting "all" of a narrower view.
const next = new Set(prev);
for (const t of topics) next.delete(t);
return next;
}
return new Set([...prev, ...topics]);
});
};
const toggleCollapsed = (section: 'friends' | 'groups') => {
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }));
};
const send = () => {
if (selected.size === 0) return;
onPick([...selected], comment.trim());
};
const renderRow = (c: Contact) => {
const isSelected = !!c.topic && selected.has(c.topic);
return (
<button
key={c.topic}
type="button"
onClick={() => c.topic && toggle(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
>
{avatarLetter(c.public?.fn)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
<SelectionCheckbox selected={isSelected} />
</button>
);
};
const renderSectionHeader = (label: string, section: 'friends' | 'groups', topics: string[], allSelected: boolean) => (
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<button type="button" onClick={() => toggleCollapsed(section)} className="flex items-center gap-0.5 text-xs text-text-tertiary-2">
<Icon name="chevron_right" className={`text-sm transition-transform ${collapsed[section] ? '' : 'rotate-90'}`} />
{label}
</button>
<button type="button" onClick={() => toggleSectionSelectAll(topics, allSelected)} className="text-xs text-accent">
{allSelected ? '取消全选' : '全选'}
</button>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索会话或联系人"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{friends.length ? (
<>
{renderSectionHeader('好友', 'friends', friendTopics, allFriendsSelected)}
{collapsed.friends ? null : friends.map(renderRow)}
</>
) : null}
{groups.length ? (
<>
{renderSectionHeader('群聊', 'groups', groupTopics, allGroupsSelected)}
{collapsed.groups ? null : groups.map(renderRow)}
</>
) : null}
{!friends.length && !groups.length ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name="search_off" className="!text-4xl" />
<span className="text-sm">未找到相关联系人</span>
</div>
) : null}
</div>
<div className="flex-none border-t border-border-list-item p-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
rows={2}
placeholder="附言(选填)"
className="w-full resize-none rounded-lg bg-surface-input p-2.5 text-sm outline-none"
/>
<button
type="button"
onClick={send}
disabled={selected.size === 0}
className="mt-2 h-9 w-full rounded-lg bg-btn text-sm font-medium text-white disabled:bg-btn-disabled"
>
发送{selected.size ? `(${selected.size})` : ''}
</button>
</div>
</div>
</div>
);
}
@@ -1,435 +0,0 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getAllGroupMembers } from '@/lib/api/groups';
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
type Tab = 'text' | MediaType;
const TABS: { key: Tab; label: string; icon: string }[] = [
{ key: 'text', label: '聊天记录', icon: 'search' },
{ key: 'image', label: '图片', icon: 'image' },
{ key: 'video', label: '视频', icon: 'videocam' },
{ key: 'file', label: '文件', icon: 'description' },
{ key: 'link', label: '链接', icon: 'link' },
];
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's
* ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that
* component directly, but reads the same `ent[0].data.{val,ref}` shape). */
function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
const data = item.ent?.[0]?.data;
const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data?.ref);
const src = inline || remote.url;
return (
<div className="relative aspect-square overflow-hidden bg-surface-rail">
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
</div>
)}
{isVideo && src ? (
<div className="absolute inset-0 bg-black/10">
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
{data?.duration ? (
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</div>
) : null}
</div>
);
}
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
* through rather than picking fields back out of it by hand. The previous version's manual
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
* search against a real plain-text message showed before this fix.
*
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
* on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape
* distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already
* document for message bubbles. */
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
const content = item.snapshot?.content;
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
const mobileAttachment = mobileAttachmentContentOf(asMessage);
if (mobileAttachment) {
const label =
mobileAttachment.kind === 'image'
? '[图片]'
: mobileAttachment.kind === 'video'
? '[视频]'
: mobileAttachment.kind === 'voice'
? '[语音]'
: `[文件] ${mobileAttachment.name || ''}`;
return <span className="text-text-body">{label}</span>;
}
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
// Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted
// data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so
// `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix
// addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns
// it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that
// something is there but unsupported, same spirit as the `(内容不可用)` placeholder above.
if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) {
return <span className="text-text-tertiary-4">[暂不支持的消息类型]</span>;
}
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
* This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that
* (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering,
* whose inline links aren't gated here (same as any other chat-bubble link).
*/
function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') });
if (!item.url) return <SnapshotContent item={item} />;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(item.url!);
} catch {
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(item.url, '_blank', 'noopener,noreferrer');
};
return (
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
<button
type="button"
onClick={open}
disabled={safetyCheck.isPending}
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
>
{item.url}
</button>
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
</div>
);
}
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<button type="button" onClick={onClick} disabled={loading} className="py-3 text-center text-sm text-accent disabled:opacity-60">
{loading ? '加载中…' : '加载更多'}
</button>
);
}
interface HistorySearchModalProps {
topicId: string;
initialTab?: Tab;
onClose: () => void;
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
isGroup: boolean;
peerName: string;
}
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
const [tab, setTab] = useState<Tab>(initialTab);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEscapeKey(onClose);
const myUserId = useSessionStore((s) => s.myUserId);
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId),
enabled: isGroup,
});
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
// prefix removal, not a lossy truncation.
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我';
if (isGroup) {
const member = membersQuery.data?.find((m) => m.user_id === full);
return groupSenderLabel(member && groupMemberDisplayName(member));
}
return peerName;
};
const searchQuery = useInfiniteQuery({
queryKey: ['topic-search', topicId, debouncedQuery],
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, 'text', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
});
const searchItems = searchQuery.data?.pages.flatMap((p) => p.items) ?? [];
const mediaQuery = useInfiniteQuery({
queryKey: ['topic-media', topicId, tab],
queryFn: ({ pageParam }) => getTopicMedia(topicId, tab as MediaFetchType, pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'image' || tab === 'video',
});
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
const fileLinkQuery = useInfiniteQuery({
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
// header element sitting above all four tabs, not a 消息-tab-only control.
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'file' || tab === 'link',
});
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
// hands off the target and gets out of the way.
const jumpToMessage = (seq: number) => {
useMessageJumpStore.getState().requestJump(topicId, seq);
onClose();
};
return (
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
// exactly as before.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
<div
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
onClick={(e) => e.stopPropagation()}
>
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
{query ? (
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
</button>
) : null}
</div>
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
取消
</button>
</header>
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
}`}
>
<Icon name={t.icon} className="text-[16px]" />
{t.label}
</button>
))}
</div>
<div className="hidden flex-none px-5 py-3 md:block">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
</div>
</div>
{tab === 'text' ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-5 pb-4">
{!debouncedQuery.trim() ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
) : searchQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
) : (
<>
<ul className="flex flex-col gap-1">
{searchItems.map((item) => {
const fullFrom = normalizeSender(item.from);
const name = senderLabel(item.from);
return (
<li key={item.seq}>
<button
type="button"
onClick={() => jumpToMessage(item.seq)}
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
>
<span
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
}`}
>
{avatarLetter(name)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span className="truncate text-text-tertiary-2">{name}</span>
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="truncate">
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
</div>
</div>
</button>
</li>
);
})}
</ul>
{searchQuery.hasNextPage ? (
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
</div>
) : tab === 'image' || tab === 'video' ? (
<div className="flex-1 overflow-y-auto p-5">
{mediaQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : mediaQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !mediaItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
{mediaItems.map((item) => (
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
))}
</div>
{mediaQuery.hasNextPage ? (
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-5">
{fileLinkQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : fileLinkQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !fileLinkItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="flex flex-col gap-2">
{fileLinkItems.map((item) =>
tab === 'link' ? (
<LinkResult key={item.seq} item={item} />
) : (
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
),
)}
</div>
{fileLinkQuery.hasNextPage ? (
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -1,89 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getGroupMembers, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useDebouncedValue } from '@/lib/hooks';
import { MENTION_ALL_SENTINEL, type PendingMention } from '@/lib/messages/mentions';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
interface MentionPickerProps {
topicId: string;
query: string;
onPick: (choice: PendingMention) => void;
}
/**
* 设计稿 4.3:输入 `@` 触发的 260px 浮层。候选来自 `GET .../members?query=&mentionable=true`
* (协议文档 1052/1735 行)-- 这一个响应同时带 `items`(已经是可提及成员,不用再拿全量列表自己过滤)
* 和 `permissions.can_mention_all`,比向 ConversationView 另外要这两样数据都便宜。"所有人"是虚拟项,
* 不对应真实成员,只在 `can_mention_all` 为真时出现在首位。
*/
export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
const debouncedQuery = useDebouncedValue(query, 200);
const membersQuery = useQuery({
queryKey: ['group-mentionable-members', topicId, debouncedQuery],
queryFn: () => getGroupMembers(topicId, { query: debouncedQuery, mentionable: true }),
});
const items = membersQuery.data?.items || [];
const canMentionAll = !!membersQuery.data?.permissions?.can_mention_all;
const showAll = canMentionAll && (!query || '所有人'.includes(query));
if (!showAll && items.length === 0 && !membersQuery.isLoading) return null;
return (
<div className="w-[260px] overflow-hidden rounded-[10px] border border-border-panel bg-white py-1 shadow-menu">
{membersQuery.isLoading ? (
<div className="px-3 py-4 text-center text-xs text-text-tertiary-4">加载中…</div>
) : (
<>
{showAll ? (
<button
type="button"
onClick={() => onPick({ userId: MENTION_ALL_SENTINEL, label: '所有人' })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-accent text-white">
<Icon name="group" className="text-[16px]" />
</span>
<span className="text-sm text-text-body">所有人</span>
</button>
) : null}
{items.map((m) => {
// Real, reported bug: `display_name` here silently prefers the CURRENT viewer's own
// private remark for this member (see GroupMember.display_name's own doc comment) --
// fine for a list only the viewer sees, wrong for an @mention, whose label gets baked
// into the message's shared content once and read by every OTHER member from then on,
// who have no idea what the sender's personal remark for this person means. `nickname`
// is that member's own real public name, unaffected by anyone's remark, so it stays
// correct for whoever else ends up reading the mention.
const label = m.group_nickname || m.nickname || m.display_name || m.user_id;
return (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(label)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{label}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
</span>
) : null}
</button>
);
})}
</>
)}
</div>
);
}
-283
View File
@@ -1,283 +0,0 @@
import type { StoredMessage, Topic } from 'tinode-sdk';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import {
callResultDisplay,
contactCardContentOf,
isRecalled,
isServerDeleted,
mobileAttachmentContentOf,
systemNoticeText,
type ContactCardContent,
} from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { QuoteBar } from '@/features/messages/QuoteBar';
import { parseReplyHead } from '@/lib/messages/reply';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
import { openUserCard } from '@/stores/userCard';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
* the real profile page, the one thing a contact-card message is fundamentally for. */
function ContactCardBubble({ content, locked }: { content: ContactCardContent; locked?: boolean }) {
const name = content.display_name || content.public_account || content.user_id;
const inner = (
<>
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
</span>
</>
);
// `locked` (selectMode): same reasoning as the avatar/@mention buttons elsewhere in this file --
// a tap must only toggle the row, not navigate away and lose whatever's already been picked.
if (locked) {
return <div className="flex w-[220px] items-center gap-3">{inner}</div>;
}
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
{inner}
</Link>
);
}
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
return null;
}
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
* for per-message attribution once there's more than one possible sender. */
senderName?: string;
senderRole?: 'owner' | 'admin' | 'member';
/** The avatar to show for whoever sent THIS row -- resolved by ConversationView per-message (its
* own `avatar_file_id`, the group member's, or the peer's, depending on `mine`/`isGroup`), not a
* single fixed prop like `peerName`, since every row in a group can have a different sender. */
avatarFileId?: string;
selectMode?: boolean;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
* context menu's target. */
menuActive?: boolean;
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
* open outline, since the two states mean different things (context menu open vs. "you asked to find
* this one") and can't reuse the same visual without conflating them. */
highlighted?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
/** Resolves a QUOTED message's sender uid (from head["x-im-reply"].from) to a display label.
* Owned by ConversationView so group remark/nickname resolution stays in one place; QuoteBar
* must never print a raw uid. */
resolveQuotedName?: (uid: string | undefined) => string;
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
* even made. */
onError?: (message: string) => void;
}
export function MessageRow({
topic,
msg,
mine,
isGroup,
peerName,
senderName,
senderRole,
avatarFileId,
selectMode,
selected,
menuActive,
highlighted,
onToggleSelect,
onContextMenu,
resolveQuotedName,
onError,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
return (
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
此消息已被删除
</div>
);
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
</div>
);
}
const failed = isFailedStatus(topic, msg);
const sending = isSendingStatus(topic, msg);
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
const mobileAttachment = mobileAttachmentContentOf(msg);
// Quoted-reply snapshot (x-im-reply). Not rendered for a recalled message -- its whole bubble
// collapses to the "消息已删除" placeholder, quote bar included.
const replyInfo = isRecalled(msg) ? null : parseReplyHead(msg.head);
const retry = () => {
if (typeof msg.seq !== 'number') return;
const seq = msg.seq;
// The original send most likely failed BECAUSE the topic was detached (see ConversationView's
// submit() for the same reasoning) -- retrying without re-attaching first would just fail the
// exact same way again.
ensureTopicAttached(topic.name)
.then(() => retryMessage(topic, seq))
.catch(() => onError?.('重试发送失败'));
};
// In selectMode, the WHOLE row toggles selection (not just the checkbox) -- confirmed by the user
// that tapping only the small checkbox target is the wrong UX for a bulk-select list. A recalled
// message renders a spacer instead of a checkbox (see below) and must stay unselectable, so this
// guards the same way.
const rowSelectable = selectMode && !isRecalled(msg) && typeof msg.seq === 'number';
const toggleThisRow = () => rowSelectable && onToggleSelect?.(msg.seq as number);
return (
<div
data-seq={msg.seq}
onClick={toggleThisRow}
className={`flex items-center gap-2 ${rowSelectable ? 'cursor-pointer' : ''} ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}
>
{selectMode ? (
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
// row over, while still refusing to let it join a batch action.
isRecalled(msg) ? (
<div className="h-5 w-5 flex-none" />
) : (
// No onClick of its own -- the row-level handler above already toggles it, and a second
// handler here would double-toggle on every click (select then immediately un-select).
<div className="flex-none">
<SelectionCheckbox selected={selected} />
</div>
)
) : null}
<div
onContextMenu={(e) => {
if (selectMode) return;
e.preventDefault();
onContextMenu?.(e, msg);
}}
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
{/* Group members' avatar/name go to their profile (same destination ContactCardBubble's own
card already uses, which works for a non-friend group member too -- FriendProfileView's
relationship-aware buttons handle that case, it isn't friends-only). Not for `mine` -- that
slot always reads "我", and viewing "your own profile" via a route built around a two-party
relationship isn't a case this app has ever needed to support. Not during `selectMode`
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
not navigate away and lose whatever's already been picked. */}
{!mine && !selectMode && msg.from ? (
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</button>
) : (
<Avatar
id={msg.from || (isGroup ? '' : peerName)}
name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : peerName}
avatarFileId={avatarFileId}
fallbackBgClass={mine ? 'bg-accent' : undefined}
className="h-9 w-9 flex-none rounded-lg text-sm"
/>
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{isGroup ? (
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && !selectMode && msg.from ? (
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
{groupSenderLabel(senderName)}
</button>
) : (
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
)}
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div>
) : null}
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
<div className="flex items-center gap-1.5">
{failed ? <Icon name="error" className="flex-none text-[20px] text-unread" /> : null}
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{replyInfo ? (
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
) : null}
{isRecalled(msg) ? (
'⚠ 消息已删除'
) : callResult ? (
<span className="flex items-center gap-1.5">
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
{callResult.text}
</span>
) : contactCard ? (
<ContactCardBubble content={contactCard} locked={selectMode} />
) : mobileAttachment ? (
mobileAttachment.kind === 'image' ? (
<ImageAttachment data={mobileAttachment} locked={selectMode} />
) : mobileAttachment.kind === 'video' ? (
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} locked={selectMode} />
) : mobileAttachment.kind === 'voice' ? (
<AudioAttachment data={mobileAttachment} locked={selectMode} />
) : (
<FileAttachment data={mobileAttachment} locked={selectMode} />
)
) : (
renderDrafty(msg.content, undefined, !selectMode)
)}
</div>
</div>
{failed ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
retry();
}}
className="text-xs text-unread"
>
发送失败,点击重试
</button>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
)}
</div>
</div>
</div>
);
}
@@ -1,115 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import type { MessageAction } from '@/lib/messages/actions';
interface MenuItem {
action: MessageAction;
label: string;
icon: string;
danger?: boolean;
}
const ITEMS: Record<MessageAction, MenuItem> = {
quote: { action: 'quote', label: '引用', icon: 'format_quote' },
copy: { action: 'copy', label: '复制', icon: 'content_copy' },
favorite: { action: 'favorite', label: '收藏', icon: 'inventory_2' },
forward: { action: 'forward', label: '转发', icon: 'reply' },
recall: { action: 'recall', label: '撤回', icon: 'undo' },
select: { action: 'select', label: '多选', icon: 'select_all' },
delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true },
};
interface MessageContextMenuProps {
x: number;
y: number;
actions: MessageAction[];
/** README §3.5: "撤回...右侧灰字剩余时限「1:42 内」" -- only meaningful when `actions` includes 'recall'. */
recallRemainingSeconds?: number | null;
onAction: (action: MessageAction) => void;
onClose: () => void;
}
function formatRecallCountdown(seconds: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} 内`;
}
/** README §3.5: 180px popup, divider before the destructive-leaning tail (recall/delete). */
export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAction, onClose }: MessageContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
useEffect(() => {
const handler = (e: KeyboardEvent | MouseEvent | Event) => {
if (e instanceof KeyboardEvent && e.key !== 'Escape') return;
onClose();
};
window.addEventListener('click', handler);
window.addEventListener('scroll', handler, true);
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('click', handler);
window.removeEventListener('scroll', handler, true);
window.removeEventListener('keydown', handler);
};
}, [onClose]);
// Right-clicking near the window's right/bottom edge would otherwise render this partially
// off-screen -- clamp against the menu's actual measured size once it's in the DOM.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8);
setPos({ left: Math.max(8, left), top: Math.max(8, top) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [x, y]);
const leading = actions.filter((a) => a !== 'recall' && a !== 'delete');
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
return (
<div
ref={ref}
style={pos}
className="fixed z-50 w-[180px] rounded-[10px] border border-border-panel bg-white p-1.5 shadow-menu"
onClick={(e) => e.stopPropagation()}
>
{leading.map((action) => {
const item = ITEMS[action];
return (
<button
key={action}
type="button"
onClick={() => onAction(action)}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm text-text-secondary hover:bg-surface-rail"
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
</button>
);
})}
{trailing.length ? <div className="my-1 h-px bg-border-list-item" /> : null}
{trailing.map((action) => {
const item = ITEMS[action];
return (
<button
key={action}
type="button"
onClick={() => onAction(action)}
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm hover:bg-surface-rail ${
item.danger ? 'text-danger-text' : 'text-text-secondary'
}`}
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
{action === 'recall' && typeof recallRemainingSeconds === 'number' ? (
<span className="ml-auto text-xs text-text-tertiary-3">{formatRecallCountdown(recallRemainingSeconds)}</span>
) : null}
</button>
);
})}
</div>
);
}
@@ -1,172 +0,0 @@
import { useLayoutEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
import { DetailColumn } from '@/features/messages/DetailColumn';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { OfflineBanner } from '@/features/messages/OfflineBanner';
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
import type { MediaType } from '@/lib/api/messages';
import { getMe } from '@/lib/api/profile';
import { useMediaQuery } from '@/lib/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
* read on every render. */
function ConversationDetailPanel({
isGroup,
topicId,
peerName,
onClose,
onOpenHistory,
}: {
isGroup: boolean;
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}) {
return isGroup ? (
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
) : (
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
);
}
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
function readPersistedDetailPanelOpen(): boolean {
try {
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
} catch {
return false;
}
}
/**
* The 4-tier responsive split described in the design handoff:
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
* provide one at this tier -- see ConversationView).
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
* affordance at all).
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
* to dock or drawer it in without covering the conversation entirely anyway.
*/
export function MessagesLayout() {
const { topicId } = useParams();
const location = useLocation();
const [detailOpen, setDetailOpen] = useState(false);
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const isDocked = useMediaQuery('(min-width: 1280px)');
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
// and effects for no visible reason, and briefly having two real instances of the same panel alive
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
// that isn't safe to run twice.
const isMdUp = useMediaQuery('(min-width: 960px)');
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
// to the same list-only view as the bare `/messages` index at narrow widths.
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
const contacts = useChatList();
const tinode = useTinode();
// `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the
// lookup below always misses for it -- same gap ConversationView.tsx's own header already had and
// fixed the same way, just computed independently here for this panel's title/detail-column
// props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has
// already mounted it, not a second request.
const isSelf = topicId === 'slf';
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
const toggleDetail = () => {
if (isDocked) {
setDetailPanelOpen((v) => {
const next = !v;
try {
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
} catch {
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
// toggle itself still works for the rest of this session.
}
return next;
});
} else {
setDetailOpen((v) => !v);
}
};
// MessagesLayout is one long-lived instance reused across every conversation (route is
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
// showing results for whichever topic it was opened against, not the one now on screen.
useLayoutEffect(() => setHistoryTab(null), [topicId]);
return (
<div className="relative flex h-full w-full">
<div className="relative flex h-full min-w-0 flex-1">
<div className="absolute inset-x-0 top-0 z-10">
<OfflineBanner />
</div>
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
<ChatListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
</div>
</div>
{isDocked && detailPanelOpen && topicId ? (
<div className="hidden h-full lg:block">
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
</div>
) : null}
{!isDocked && isMdUp && detailOpen && topicId ? (
<div className="absolute inset-0 hidden md:block lg:hidden">
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
</div>
) : null}
{!isDocked && !isMdUp && detailOpen && topicId ? (
<div className="fixed inset-0 z-30 bg-white md:hidden">
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
) : null}
{historyTab && topicId ? (
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
) : null}
</div>
);
}
@@ -1,50 +0,0 @@
import { Icon } from '@/app/Icon';
import { retryReconnectNow } from '@/lib/tinode/connection';
import { useReconnectState } from '@/lib/tinode/hooks';
/**
* README §3.7's top-banner states, overlaying ChatList+Main. Only one shows at a time, priority:
* offline > reconnecting > just-reconnected (most to least urgent). "已重新连接,正在同步 N 条新
* 消息" is shown without a fake N -- the old app never implemented this state at all (confirmed by
* research), and there's no reliable signal here for "how many messages arrived while disconnected,"
* so inventing a number would just be dishonest data.
*/
export function OfflineBanner() {
const reconnect = useReconnectState();
if (!reconnect.networkOnline) {
return (
<div className="flex h-10 flex-none items-center gap-2 bg-error-strip-bg px-4 text-[13px] text-error-strip-text">
<Icon name="wifi_off" className="text-[16px]" />
当前无网络连接,消息将在恢复后自动发送
</div>
);
}
if (reconnect.retryInSeconds > 0 || reconnect.attemptInFlight) {
return (
<div className="flex h-10 flex-none items-center justify-between bg-announce-bg px-4 text-[13px] text-announce-text">
<span className="flex items-center gap-2">
<Icon name="autorenew" className="animate-spin text-[16px] text-announce" />
{reconnect.attemptInFlight ? '连接已断开,正在重连…' : `连接已断开,正在重连(${reconnect.retryInSeconds} 秒后重试)…`}
</span>
{!reconnect.attemptInFlight ? (
<button type="button" onClick={retryReconnectNow} className="text-accent">
立即重试
</button>
) : null}
</div>
);
}
if (reconnect.justReconnected) {
return (
<div className="flex h-10 flex-none items-center gap-2 bg-info-strip-bg px-4 text-[13px] text-info-strip-text">
<Icon name="sync" className="text-[16px] text-accent" />
已重新连接,正在同步消息…
</div>
);
}
return null;
}
-143
View File
@@ -1,143 +0,0 @@
import { useMemo } from 'react';
import { Icon } from '@/app/Icon';
import { useAuthedBlobUrl } from '@/lib/hooks';
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
import {
placeholderForType,
quotedMediaOf,
type ReplyInfo,
type QuoteMedia,
} from '@/lib/messages/reply';
import { useTopicMessages } from '@/lib/tinode/hooks';
import { useMessageJumpStore } from '@/stores/messageJump';
import type { StoredMessage } from 'tinode-sdk';
/**
* Quote bar rendered at the top of a bubble, driven exclusively by `head["x-im-reply"]`
* (cross-client contract aligned with iOS, 2026-09-23):
*
* - the head only carries a lightweight snapshot {seq,type,text,from}; the quoted message itself
* is re-resolved from the LOCAL message cache by seq, which unlocks real thumbnails / voice
* durations and the "该消息已删除" state that no snapshot can express;
* - when the quoted message isn't local (fresh device, cleared history, unloaded range), the
* snapshot carries rendering alone -- localized placeholder per type, never blank;
* - clicking requests a scroll-locate + highlight via the same messageJump store the history
* search uses; it transparently fetches the surrounding range when the target isn't loaded.
*
* Deliberately a plain <button>-free clickable div: it must not submit anything, but must allow
* nested long-press/selection behavior of the bubble itself.
*/
function formatVoiceDuration(ms: number | undefined): string {
if (typeof ms !== 'number' || ms <= 0) return '';
return `${Math.max(1, Math.round(ms / 1000))}″`;
}
/** Small authenticated thumbnail (28x28 box per iOS spec) for image/video quotes. */
function QuoteThumb({ media }: { media: { val?: string; ref?: string; mime?: string } }) {
const inline = typeof media.val === 'string' ? `data:${media.mime || 'image/jpeg'};base64,${media.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : media.ref);
const src = inline || remote.url;
if (!src) {
return (
<span className="grid h-7 w-7 flex-none place-items-center rounded bg-black/[0.06]">
<Icon name="image" className="text-[15px] text-text-tertiary-3" />
</span>
);
}
return <img src={src} alt="" className="h-7 w-7 flex-none rounded object-cover" />;
}
function MediaSummary({ media }: { media: QuoteMedia }) {
if ((media.kind === 'image' || media.kind === 'video') && media.image) {
return (
<span className="flex min-w-0 flex-none items-center gap-1">
{media.kind === 'video' ? <Icon name="play_circle" className="flex-none text-[14px]" /> : null}
<QuoteThumb media={media.image} />
</span>
);
}
if (media.kind === 'voice') {
const seconds = formatVoiceDuration(media.durationMs);
return (
<span className="flex flex-none items-center gap-1">
<Icon name="mic_none" className="text-[14px]" />
{seconds ? <span>{seconds}</span> : null}
</span>
);
}
return <Icon name="insert_drive_file" className="flex-none text-[14px]" />;
}
interface QuoteBarProps {
topicName: string;
reply: ReplyInfo;
/** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent
* already owns this lookup, and must never surface a raw uid to the user. */
resolveName: (uid: string | undefined) => string;
/** MessageBubble's selectMode: a tap here must only toggle the row's own selection (same reasoning
* as the avatar/sender-name buttons going inert during bulk-select), not also jump to the quoted
* message -- so `locate` is left unwired and this renders as a plain, non-interactive div instead
* of `role="button"`. */
locked?: boolean;
}
export function QuoteBar({ topicName, reply, resolveName, locked }: QuoteBarProps) {
const messages = useTopicMessages(topicName);
const quoted: StoredMessage | undefined = useMemo(
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
[messages, reply.seq],
);
const deleted = !!quoted && (isRecalled(quoted) || isServerDeleted(quoted));
// Local media is only usable when the quoted message is present AND alive -- a recalled message's
// thumbnail must not leak back through the quote bar (the snapshot is the only legal residue).
const localMedia = quoted && !deleted ? quotedMediaOf(quoted) : null;
const name = resolveName(quoted?.from || reply.from);
// Per the cross-client spec, only the media/card-like types render as placeholders; any unknown
// type is rendered as text using the snapshot body.
const MEDIA_LIKE_TYPES = new Set(['image', 'video', 'voice', 'file', 'contact_card', 'forward_bundle', 'sticker']);
const isTextLike = !MEDIA_LIKE_TYPES.has(reply.type);
// Real media affordances only when the local copy actually carries what they need: a usable image
// ref for image/video, a duration for voice. Otherwise fall back to the snapshot placeholder
// (exactly the iOS "取不到缩略图 → 名字:[图片]" / "取不到 → 名字:[语音]" behavior).
const hasThumb = !!localMedia && (localMedia.kind === 'image' || localMedia.kind === 'video') && !!localMedia.image;
const hasVoiceDuration = localMedia?.kind === 'voice' && typeof localMedia.durationMs === 'number' && localMedia.durationMs > 0;
const locate = () => {
useMessageJumpStore.getState().requestJump(topicName, reply.seq);
};
if (deleted) {
return (
<div
role={locked ? undefined : 'button'}
tabIndex={locked ? undefined : 0}
onClick={locked ? undefined : locate}
className="mb-1.5 truncate rounded border-l-2 border-text-tertiary-3 bg-black/[0.04] px-2 py-1 text-xs italic text-text-tertiary-3"
>
该消息已删除
</div>
);
}
return (
<div
role={locked ? undefined : 'button'}
tabIndex={locked ? undefined : 0}
onClick={locked ? undefined : locate}
title={locked ? undefined : '查看原消息'}
className="mb-1.5 flex max-w-full items-center gap-1.5 rounded border-l-2 border-accent bg-black/[0.04] px-2 py-1 text-xs text-[#4a4f57]"
>
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
{hasThumb || hasVoiceDuration ? <MediaSummary media={localMedia as QuoteMedia} /> : null}
{isTextLike ? (
<span className="line-clamp-2 min-w-0 break-words">{reply.text}</span>
) : (
<span className="min-w-0 truncate">{reply.text || placeholderForType(reply.type)}</span>
)}
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
import type { ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
import { checkUrlSafety } from '@/lib/api/security';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
* Same UX contract as HistorySearchModal.tsx's own (dead-code, see its comment) LinkResult -- this is
* the renderer (lib/messages/drafty.tsx's `LN` case) that component itself falls back to for a hit
* that doesn't carry its own `url` enrichment field, so fixing it here covers both call sites: this
* was previously the only actually-reachable one, since SearchHit's real response has no `url` field.
*/
export function SafeLink({ href, children }: { href: string | undefined; children: ReactNode }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'chat_message') });
if (!href) return <span className="text-accent underline">{children}</span>;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(href);
} catch {
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(href, '_blank', 'noopener,noreferrer');
};
return (
<button type="button" onClick={open} disabled={safetyCheck.isPending} className="text-accent underline disabled:opacity-60">
{children}
</button>
);
}
-147
View File
@@ -1,147 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { MAX_DURATION, MIN_DURATION } from '@/lib/config';
// Chrome/Firefox vs Safari support different codecs for MediaRecorder -- ported from the old app's
// audio-recorder.jsx, first-supported-wins probe.
const AUDIO_MIME_TYPES = ['audio/webm', 'audio/mp4', ''];
function formatSeconds(s: number): string {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
interface VoiceRecorderProps {
onSend: (blob: Blob, durationMs: number) => void;
onCancel: () => void;
}
/**
* README §3.3's recording state: click-to-start (from the composer's mic icon), explicit 取消/发送
* buttons -- not the old app's press-and-hold-with-swipe-to-cancel gesture, which the new design
* replaces with plain buttons (a reasonable desktop-first adaptation; the underlying recording
* mechanics are the same). Silently cancels (no message sent) if the recording is shorter than
* MIN_DURATION, matching the old app's "too short, discard" behavior.
*/
export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
const [seconds, setSeconds] = useState(0);
const [ready, setReady] = useState(false);
const [error, setError] = useState('');
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const startedAtRef = useRef(0);
const finishedRef = useRef(false);
const stopStream = () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
const finish = (send: boolean) => {
if (finishedRef.current) return;
finishedRef.current = true;
const recorder = recorderRef.current;
if (!recorder || recorder.state === 'inactive') {
stopStream();
onCancel();
return;
}
const durationMs = Date.now() - startedAtRef.current;
recorder.onstop = () => {
stopStream();
if (send && durationMs >= MIN_DURATION) {
onSend(new Blob(chunksRef.current, { type: recorder.mimeType }), durationMs);
} else {
onCancel();
}
};
recorder.stop();
};
useEffect(() => {
let cancelled = false;
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const mimeType = AUDIO_MIME_TYPES.find((t) => MediaRecorder.isTypeSupported(t));
const recorder = new MediaRecorder(stream, mimeType ? { mimeType, audioBitsPerSecond: 24_000 } : undefined);
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
// Without this, a mid-recording failure (mic unplugged, OS audio error) auto-transitions
// the recorder to 'inactive' with nothing telling the UI -- the "录音中" timer just keeps
// ticking forever with no way to know the recording already died until the user manually
// hits 取消/发送 and finish()'s own `recorder.state === 'inactive'` check silently no-ops.
recorder.onerror = () => {
if (finishedRef.current) return;
finishedRef.current = true;
stopStream();
setError('录音失败,请重试');
};
recorder.start();
recorderRef.current = recorder;
startedAtRef.current = Date.now();
setReady(true);
})
.catch(() => setError('无法访问麦克风,请检查权限设置'));
return () => {
cancelled = true;
stopStream();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!ready) return undefined;
const timer = window.setInterval(() => {
setSeconds((s) => {
const next = s + 1;
if (next * 1000 >= MAX_DURATION) finish(true);
return next;
});
}, 1000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready]);
if (error) {
return (
<div className="flex h-14 items-center justify-between rounded-lg bg-error-strip-bg px-4 text-sm text-error-strip-text">
{error}
<button type="button" onClick={onCancel} className="text-accent">
知道了
</button>
</div>
);
}
return (
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
<span className="flex flex-none items-center gap-2 text-sm font-medium text-text-body">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-unread" />
录音中 {formatSeconds(seconds)}
</span>
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
<div className="flex flex-none items-center gap-4">
<button type="button" onClick={() => finish(false)} className="text-sm text-text-secondary">
取消
</button>
<button
type="button"
onClick={() => finish(true)}
disabled={!ready}
className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled"
>
<Icon name="send" className="text-[16px]" />
发送
</button>
</div>
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
export interface FriendAvatarSource {
user_id: string;
topic?: string;
avatar_file_id?: string;
}
/**
* P2P contacts are keyed by their `p2p...` topic after the first subscription,
* while the friends API identifies the same person with a `usr...` user ID.
* Keep both aliases so avatars work during both the initial and established
* conversation states.
*/
export function friendAvatarLookup(friends: FriendAvatarSource[] = []) {
const avatars = new Map<string, string | undefined>();
for (const friend of friends) {
if (friend.user_id) avatars.set(friend.user_id, friend.avatar_file_id);
if (friend.topic) avatars.set(friend.topic, friend.avatar_file_id);
}
return avatars;
}
@@ -1,45 +0,0 @@
import { useState } from 'react';
import { Toast } from '@/app/Toast';
import { requestConfirm } from '@/stores/confirmDialog';
/**
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
* all (docs/IM-后端问题清单.md-adjacent research: "No backend endpoint clears cache/history -- both
* are pure client-side operations"). Computing a real, honest "just this app's cache" byte count is
* not reliably possible (`navigator.storage.estimate()` reports the WHOLE origin, not a
* per-feature breakdown) -- rather than show a fabricated-looking number, this omits the size and
* just performs and confirms the real clear (this app's own Cache Storage buckets, per the SW-
* caching behavior already relied on elsewhere in this app).
*/
export function GeneralSettingsPage() {
const [status, setStatus] = useState<'idle' | 'clearing' | 'done'>('idle');
const [error, setError] = useState('');
const clearCache = async () => {
if (!(await requestConfirm({ message: '确定清除本地缓存吗?不会影响你的账号数据。' }))) return;
setStatus('clearing');
try {
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
setStatus('done');
} catch (err) {
setStatus('idle');
setError(err instanceof Error ? err.message : '清除缓存失败,请重试');
}
};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-4 text-xl font-semibold text-text-heading">通用</h2>
<div className="rounded-xl bg-white text-sm">
<button type="button" onClick={clearCache} disabled={status === 'clearing'} className="flex w-full items-center justify-between px-7 py-4 text-left">
<span className="text-text-body">清空缓存</span>
<span className="text-text-tertiary-2">{status === 'clearing' ? '清除中…' : status === 'done' ? '已清除 ›' : '›'}</span>
</button>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -1,42 +0,0 @@
import { useState } from 'react';
/**
* README §8.1's directory lists a dedicated "消息通知" section, but there is no per-type
* notification-preference REST endpoint anywhere (confirmed: grepped both protocol-doc copies for
* notification_settings/推送偏好, zero hits; the old app's own settings list has no such row
* either) -- the doc's own claim that "服务端只保存应用内通知偏好" has nothing backing it. Rather
* than build toggles that write nowhere, this only wires the one thing that's real and entirely
* client-side: the browser's own Notification permission.
*/
export function NotificationsSettingsPage() {
const [permission, setPermission] = useState<NotificationPermission>(typeof Notification !== 'undefined' ? Notification.permission : 'denied');
const request = async () => {
if (typeof Notification === 'undefined') return;
const result = await Notification.requestPermission();
setPermission(result);
};
const label = { granted: '已允许', denied: '已拒绝', default: '未设置' }[permission];
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-4 text-xl font-semibold text-text-heading">消息通知</h2>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between px-7 py-4">
<div className="flex flex-col">
<span className="text-text-body">桌面通知</span>
<span className="text-xs text-text-tertiary-3">由浏览器管理,用于在新消息到达时提醒你</span>
</div>
{permission === 'default' ? (
<button type="button" onClick={request} className="h-8 flex-none rounded-lg bg-btn px-3 text-xs font-medium text-white">
开启
</button>
) : (
<span className={`flex-none text-xs ${permission === 'granted' ? 'text-success' : 'text-text-tertiary-2'}`}>{label}</span>
)}
</div>
</div>
</div>
);
}
@@ -1,166 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { getBlocks } from '@/lib/api/friends';
import { getMyPrivacy, updateMyPrivacy, type AddMeMethods } from '@/lib/api/profile';
const ADD_ME_LABELS: { key: keyof AddMeMethods; label: string }[] = [
{ key: 'account', label: '通过用户 ID' },
{ key: 'phone', label: '通过手机号' },
{ key: 'qr', label: '通过二维码' },
{ key: 'group', label: '通过群聊' },
];
const VISIBILITY_OPTIONS = [
{ value: 'everyone', label: '所有人' },
{ value: 'friends', label: '仅好友' },
{ value: 'nobody', label: '不展示' },
];
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
</button>
);
}
/**
* screens/07-profile-settings.dc.html#1c: 添加我的方式 (4 toggles, ungrouped -- no dividers between
* them, just the section label above) sits in its own card; 黑名单 sits in a second card alone
* (that mockup also puts "加我为好友时需要验证" there, but that toggle has NO backing field anywhere
* in this response shape or the group-invite backend logic -- confirmed absent, not built). The
* visibility/read-receipts rows below have no mockup at all (added once the real backend fields were
* found during research) -- given their own third card rather than folding them into either of the
* above, so a real capability the design never anticipated doesn't look like it was crammed in.
*/
export function PrivacySettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = useState('');
const query = useQuery({ queryKey: ['my-privacy'], queryFn: getMyPrivacy });
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const mutation = useMutation({
mutationFn: (patch: Parameters<typeof updateMyPrivacy>[0]) => updateMyPrivacy(patch),
// Merge, don't replace -- the doc's own worked example for this exact endpoint (line 2494)
// returns a PARTIAL object (`{add_me_methods,read_receipts,version,updated_at}`, missing
// `profile_visibility`/`last_seen_visibility`/`editable`). A blind replace would erase those
// from the cache after every single save, most importantly `editable`: once it silently
// disappeared, `const editable = query.data.editable || {}` re-derives an EMPTY object, which
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
// to restore it.
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
// Same reasoning as ProfileSettingsPage's own saveMutation: a version conflict shouldn't leave
// the cached `version` stale, or every retry just resends the same rejected value forever.
onError: (err) => {
setError(err instanceof Error ? err.message : '设置失败');
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
},
});
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
if (!query.data) return;
mutation.mutate({ ...partial, version: query.data.version });
};
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
// `isLoading` alone used to gate this: it turns false once the fetch SETTLES, including into an
// error, but `query.data` stays undefined either way -- the page was falling through to the same
// "加载中…" text forever on a genuine failure (a slow/dropped connection, not just "still loading"),
// with no retry affordance at all. Same fix already applied to SystemMessagesView's own query.
if (query.isError || !query.data) {
return (
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
);
}
const editable = query.data.editable || {};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">隐私</h2>
<div className="flex flex-col gap-5">
<div className="rounded-xl bg-white text-sm">
<div className="px-7 pb-1.5 pt-4 text-[13px] text-text-tertiary-2">添加我的方式</div>
{ADD_ME_LABELS.map((item, i) => (
<div key={item.key} className={`flex items-center justify-between px-7 py-3 ${i === ADD_ME_LABELS.length - 1 ? 'pb-4.5' : ''}`}>
<span className="text-text-body">{item.label}</span>
<Toggle
checked={query.data.add_me_methods[item.key]}
disabled={editable.add_me_methods === false || mutation.isPending}
onChange={() => patch({ add_me_methods: { [item.key]: !query.data.add_me_methods[item.key] } })}
/>
</div>
))}
</div>
<button type="button" onClick={() => navigate('/contacts/blocklist')} className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm">
<span className="text-text-body">黑名单</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
{blocksQuery.data?.length ?? 0} 人
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
<span className="text-text-body">资料可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={editable.profile_visibility === false || mutation.isPending}
onClick={() => patch({ profile_visibility: opt.value })}
className={`px-3 py-1.5 ${query.data.profile_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
<span className="text-text-body">最近在线可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={editable.last_seen_visibility === false || mutation.isPending}
onClick={() => patch({ last_seen_visibility: opt.value })}
className={`px-3 py-1.5 ${query.data.last_seen_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between px-7 py-4">
<span className="text-text-body">已读回执</span>
<Toggle
checked={query.data.read_receipts}
disabled={editable.read_receipts === false || mutation.isPending}
onChange={() => patch({ read_receipts: !query.data.read_receipts })}
/>
</div>
</div>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -1,294 +0,0 @@
import { forwardRef, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { ImApiError } from '@/lib/api/client';
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
import { uploadFile } from '@/lib/api/upload';
import { useSessionStore } from '@/stores/session';
const GENDER_OPTIONS: { value: 'male' | 'female' | 'other'; label: string }[] = [
{ value: 'male', label: '男' },
{ value: 'female', label: '女' },
{ value: 'other', label: '保密' },
];
const DEFAULT_MAX_SIGNATURE_LENGTH = 60;
/** Plain-text-looking field: a real `<input>` with all box chrome stripped so it reads as the
* design's "label / value / edit-affordance-icon" row instead of a boxed form control, while
* staying genuinely editable (click anywhere in the value cell to type). */
const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function InlineField(props, ref) {
return (
<input
{...props}
ref={ref}
className={`w-full border-0 bg-transparent p-0 text-sm text-text-body outline-none [&::-webkit-calendar-picker-indicator]:hidden ${props.className || ''}`}
/>
);
});
/**
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
* `/v0/file/u/` channel (the one every other upload in this app already goes through), NOT
* `POST /v1/im/public/uploads` -- that's a different, pre-login-only channel for enterprise
* registration/tenant feedback (`lib/api/tenant.ts`'s `uploadPublicFile`, whose own `purpose` type
* is explicitly `'enterprise_license' | 'tenant_entry_feedback'`), never meant for an authenticated
* user's own avatar. Passes `purpose: 'user_avatar'` explicitly per docs/IM-前后端对接文档.md:2293
* -- this used to be omitted entirely (traced to `purpose=user_avatar` 400ing on THIS channel too,
* docs/IM-后端问题清单.md 问题28, since confirmed fixed: a real PNG upload with that purpose now
* returns 200).
*/
export function ProfileSettingsPage() {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const query = useQuery({ queryKey: ['my-profile'], queryFn: getMyProfile });
const [draft, setDraft] = useState<{ nickname: string; gender: string; birth_date: string; region: string; signature: string } | null>(null);
const [error, setError] = useState('');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [showQrCode, setShowQrCode] = useState(false);
const birthDateRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (query.data && !draft) {
setDraft({
nickname: query.data.nickname,
gender: query.data.gender || '',
birth_date: query.data.birth_date || '',
region: query.data.region || '',
signature: query.data.signature || '',
});
}
}, [query.data, draft]);
// saveMutation and avatarMutation both PATCH the same optimistic-locked resource. The disabled=
// props below on the avatar input/save button stop the two from ever being pending at the same
// time, closing the race where both could send the SAME stale `query.data.version` and whichever
// response resolves last would win the cache. Independent of that: EITHER mutation's own response
// can itself be a PARTIAL profile object -- the doc's own worked example for this endpoint (line
// 2492) returns only `{avatar_file_id,nickname,signature,version,updated_at}`, omitting
// `birth_date`/`gender`/`region`/`public_account`/`field_rules` entirely, and avatarMutation's own
// request body is deliberately partial (`{avatar_file_id,version}` only) so its response is the
// most likely of the two to come back partial too. A blind `setQueryData(key, data)` replace would
// erase those fields from the cache after a single avatar change -- merging onto the existing
// cached object (not just overwriting it) is what actually prevents that regardless of exactly how
// partial a given response turns out to be.
const saveMutation = useMutation({
mutationFn: () => {
if (!query.data || !draft) throw new Error('尚未加载');
return updateMyProfile({ ...draft, version: query.data.version });
},
onSuccess: (data) => {
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['me'] });
},
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
// on another tab/device) 400s here, but the cached `query.data.version` used to just sit there
// unchanged -- every retry would resend the exact same stale version and 400 again forever, the
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. Worse: even
// once `query.data` DOES refetch fresh, `draft` (what's actually on screen and what a follow-up
// save would resubmit) was never resynced -- a second "保存" click would then silently succeed
// and overwrite whichever OTHER fields changed elsewhere with this tab's own stale values.
onError: (err) => {
if (err instanceof ImApiError && err.reason === 'VERSION_CONFLICT') {
setError('资料已在其他设备更新,你的修改未保存,请重新编辑');
// Resets to null (not the fresh data directly, which isn't available here yet) so the
// effect above repopulates `draft` once the invalidated query below refetches -- same path
// as the initial load, guaranteeing `draft` never observably lags `query.data`'s version.
setDraft(null);
} else {
setError(err instanceof Error ? err.message : '保存失败');
}
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
},
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
const uploaded = await uploadFile(file, undefined, 'user_avatar');
if (!query.data) throw new Error('尚未加载');
return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version });
},
onSuccess: (data) => {
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['me'] });
setUploadingAvatar(false);
},
onError: (err) => {
setError(err instanceof Error ? err.message : '头像上传失败');
setUploadingAvatar(false);
},
});
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
setError('图片大小不能超过 5MB');
return;
}
setUploadingAvatar(true);
avatarMutation.mutate(file);
};
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
// Same gap as PrivacySettingsPage's own query: `draft` only ever gets set from a successful fetch
// (the effect above is gated on `query.data`), so a genuine fetch failure left `isLoading` false
// but `draft` permanently null -- falling through to the exact same "加载中…" text forever, with no
// way to retry.
if (query.isError || !draft) {
return (
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
);
}
const maxSignatureLength = query.data?.field_rules?.signature?.max_length || DEFAULT_MAX_SIGNATURE_LENGTH;
const dirty =
query.data &&
(draft.nickname !== query.data.nickname ||
draft.gender !== (query.data.gender || '') ||
draft.birth_date !== (query.data.birth_date || '') ||
draft.region !== (query.data.region || '') ||
draft.signature !== (query.data.signature || ''));
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">个人资料</h2>
<div className="flex flex-col gap-5">
<div className="flex items-center gap-5 rounded-xl bg-white px-7 py-6">
<div className="relative flex-none">
<Avatar id={myUserId || ''} name={draft.nickname} avatarFileId={query.data?.avatar_file_id} className="h-20 w-20 rounded-[20px] text-2xl" />
<label className="absolute -bottom-1 -right-1 grid h-[26px] w-[26px] cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm">
<Icon name="photo_camera" className="text-[15px]" />
<input
type="file"
accept="image/png,image/jpeg"
onChange={handlePickAvatar}
className="hidden"
disabled={uploadingAvatar || saveMutation.isPending}
/>
</label>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<strong className="truncate text-lg font-semibold text-text-heading">{draft.nickname}</strong>
<span className="truncate text-[13px] text-text-tertiary-2">
ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
</span>
</div>
<button
type="button"
onClick={() => setShowQrCode(true)}
className="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-[#d5d9df] px-2.5 text-[13px] text-text-tertiary-1 sm:px-3.5"
>
<Icon name="qr_code_2" className="text-[18px]" />
<span className="hidden sm:inline">我的二维码</span>
</button>
</div>
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
<div className="rounded-xl bg-white text-sm">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">昵称</span>
<InlineField
value={draft.nickname}
onChange={(e) => setDraft({ ...draft, nickname: e.target.value })}
maxLength={query.data?.field_rules?.nickname?.max_length || 32}
/>
<Icon name="edit" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">性别</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setDraft({ ...draft, gender: opt.value })}
className={`px-3.5 py-1.5 ${draft.gender === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
<span />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">出生日期</span>
<InlineField
ref={birthDateRef}
type="date"
value={draft.birth_date}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => setDraft({ ...draft, birth_date: e.target.value })}
/>
<button type="button" onClick={() => birthDateRef.current?.showPicker?.()} className="grid place-items-center text-icon-arrow">
<Icon name="calendar_today" className="text-[20px]" />
</button>
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">地区</span>
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-start gap-x-2 px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="pt-2 text-text-secondary">个性签名</span>
<div className="relative">
<textarea
value={draft.signature}
onChange={(e) => setDraft({ ...draft, signature: e.target.value.slice(0, maxSignatureLength) })}
rows={3}
className="min-h-[60px] w-full resize-none rounded-lg bg-surface-input p-3 pr-14 text-sm outline-none"
/>
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
{draft.signature.length}/{maxSignatureLength}
</span>
</div>
<span />
</div>
</div>
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={() =>
query.data &&
setDraft({
nickname: query.data.nickname,
gender: query.data.gender || '',
birth_date: query.data.birth_date || '',
region: query.data.region || '',
signature: query.data.signature || '',
})
}
disabled={!dirty}
className="h-9.5 rounded-lg border border-[#d5d9df] px-5 text-sm text-text-tertiary-1 disabled:opacity-50"
>
取消
</button>
<button
type="button"
onClick={() => saveMutation.mutate()}
disabled={!dirty || saveMutation.isPending || uploadingAvatar}
className="h-9.5 rounded-lg bg-btn px-5 text-sm font-medium text-white disabled:bg-btn-disabled"
>
{saveMutation.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -1,189 +0,0 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getTinode, resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { useSessionStore } from '@/stores/session';
/**
* screens/07-profile-settings.dc.html#1b. That mockup's 手机号/登录设备 rows have no real data
* source and aren't built as decorative fakes: phone number display (no `GET my phone` endpoint
* anywhere -- the only place a phone surfaces is a masked hint as a SIDE EFFECT of starting an
* unrelated SMS challenge, not a passive read) and the logged-in-device list (only a kick-by-id
* DELETE exists, itself marked 待实现, with no GET list endpoint to populate rows from). Password
* change and account deletion both go through native Tinode `{acc}`/`{del}` calls (see
* tinode-sdk.d.ts's own comments) -- there is no REST path for either, confirmed across both doc
* copies and the backend problem list.
*/
export function SecuritySettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const tenant = useSessionStore((s) => s.tenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [changingPassword, setChangingPassword] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [deletingAccount, setDeletingAccount] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Real per-tenant policy (docs/IM-前后端对接文档.md:534, fetched once at tenant-resolve time and
// stored on `tenant`) -- was hardcoded to 6 here, silently DIFFERENT from Register/ResetPassword's
// own (also-hardcoded, but at least matching each other) 8, so a password this page accepted could
// still get rejected by the backend's real policy.
const minPasswordLength = tenant?.passwordPolicy?.minLength ?? 8;
const passwordsMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
const passwordMutation = useMutation({
// `updateAccountBasic`'s own type comment (tinode-sdk.d.ts) already confirms, from this
// engagement's earlier research into the old app's real working code: the server does NOT
// verify a "current password" for this call at all -- an already-authenticated WS session is
// by itself sufficient to change the password to anything. `currentPassword` below is therefore
// NOT sent anywhere and is NOT a real credential check -- it exists purely as a client-side "did
// you mean to do this" gate (the submit button stays disabled while it's empty), the same
// fat-finger/shared-device speed bump most password-change screens have, not a security boundary.
// A REAL re-verification would need the protocol's own `POST /security/challenges` sensitive-
// action flow, but the only confirmed `scene` values for that endpoint (`set_independent_password`/
// `close_youth_mode`) are both youth-mode-specific -- and youth mode was removed from this app
// entirely per an earlier explicit user request, so that path isn't available here either.
mutationFn: () => {
const tinode = getTinode();
return tinode.updateAccountBasic(null, tinode.getCurrentLogin(), newPassword);
},
onSuccess: () => {
setChangingPassword(false);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setSuccess('密码已修改。');
},
onError: (err) => setError(err instanceof Error ? err.message : '密码修改失败'),
});
const deleteAccount = async () => {
if (deletingAccount) return;
if (!(await requestConfirm({ message: '注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?', danger: true }))) return;
if (!(await requestConfirm({ message: '请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。', danger: true }))) return;
setDeletingAccount(true);
try {
await getTinode().delCurrentUser(true);
resetTinode();
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
// Deliberately NOT clearTenant() -- see SettingsLayout.tsx's logout for why: it clears
// useSessionStore's `tenant`, and LoginPage's own tenant guard immediately bounces navigate(
// '/login') below back to the tenant-code entry screen instead of this tenant's login page.
//
// Same shared-device concern as SettingsLayout's logout -- a next login in this tab should
// never briefly render this now-deleted account's cached data.
queryClient.clear();
navigate('/login');
} catch (err) {
setError(err instanceof Error ? err.message : '注销失败');
setDeletingAccount(false);
}
};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">账号与安全</h2>
<div className="flex flex-col gap-5">
<div className="rounded-xl bg-white text-sm">
{changingPassword ? (
<form
onSubmit={(e) => {
e.preventDefault();
if (currentPassword.length === 0 || newPassword.length < minPasswordLength || passwordsMismatch || confirmPassword.length === 0 || passwordMutation.isPending) return;
passwordMutation.mutate();
}}
className="flex flex-col gap-3 px-7 py-4"
>
<span className="text-text-body">修改密码</span>
<input
type="password"
autoFocus
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="输入当前密码"
autoComplete="current-password"
className="rounded-md bg-surface-input px-3 py-2 outline-none"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={`输入新密码(至少 ${minPasswordLength} 位)`}
autoComplete="new-password"
maxLength={tenant?.passwordPolicy?.maxLength}
className="rounded-md bg-surface-input px-3 py-2 outline-none"
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入新密码"
autoComplete="new-password"
maxLength={tenant?.passwordPolicy?.maxLength}
className={`rounded-md bg-surface-input px-3 py-2 outline-none ${passwordsMismatch ? 'border border-danger-text' : ''}`}
/>
{passwordsMismatch ? <span className="text-xs text-danger-border">两次输入的密码不一致</span> : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setChangingPassword(false);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
}}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary"
>
取消
</button>
<button
type="submit"
disabled={
currentPassword.length === 0 ||
newPassword.length < minPasswordLength ||
passwordsMismatch ||
confirmPassword.length === 0 ||
passwordMutation.isPending
}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
{passwordMutation.isPending ? '保存中…' : '保存'}
</button>
</div>
</form>
) : (
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
<span className="text-text-body">修改密码</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
)}
</div>
<button
type="button"
onClick={deleteAccount}
disabled={deletingAccount}
className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm disabled:opacity-50"
>
<span className="flex flex-col gap-0.5">
<span className="text-text-body">注销账号</span>
<span className="text-xs text-text-tertiary-2">{deletingAccount ? '注销中…' : '注销后数据不可恢复'}</span>
</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
<Toast message={success} variant="success" onDone={() => setSuccess('')} />
</div>
);
}
@@ -1,15 +0,0 @@
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
import { useMediaQuery } from '@/lib/hooks';
/**
* `/settings` alone has no natural "empty" content the way contacts/messages do -- on desktop
* (directory + content docked side by side) the most-relevant first section fills the content pane
* directly. On narrow viewports this must NOT redirect there (an earlier version did): SettingsLayout
* only shows the directory list at this exact bare path, and a redirect away from it would bounce
* straight past that list before a phone-width visitor ever saw it -- so this renders nothing there,
* leaving the directory (rendered by the parent layout) as the whole screen.
*/
export function SettingsHome() {
const isDesktop = useMediaQuery('(min-width: 960px)');
return isDesktop ? <ProfileSettingsPage /> : null;
}
@@ -1,238 +0,0 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { logout as revokeServerSession } from '@/lib/api/auth';
import { getMe } from '@/lib/api/profile';
import { resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { notImplemented } from '@/stores/globalToast';
import { useSessionStore } from '@/stores/session';
const DIRECTORY = [
{ key: 'profile', to: '/settings/profile', icon: 'account_circle', label: '个人资料' },
{ key: 'security', to: '/settings/security', icon: 'security', label: '账号与安全' },
{ key: 'notifications', to: '/settings/notifications', icon: 'notifications', label: '消息通知' },
{ key: 'privacy', to: '/settings/privacy', icon: 'lock', label: '隐私' },
{ key: 'general', to: '/settings/general', icon: 'tune', label: '通用' },
];
/**
* README §8.7 (mobile "我的" page): 我的收藏/通话记录 are grouped ABOVE the regular settings
* directory. Desktop never needs this list -- those already have their own permanent NavRail icons
* there -- but on a phone-width viewport (bottom tab bar instead of a rail) there is otherwise NO
* way to reach `/favorites` or `/calls` at all short of typing the URL directly (confirmed live:
* BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2 when those two routes didn't
* exist yet) -- a real navigation gap this Phase 9 pass caught, not a cosmetic one. 文件助手 used to
* be a third entry here (reusing the same `slf`-conversation redirect the desktop NavRail icon did)
* but was removed along with every other entry point -- see ChatListColumn.tsx's PINNED_ENTRIES
* doc comment for why.
*/
const MOBILE_SHORTCUTS = [
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
];
/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */
export function SettingsLayout() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
const [loggingOut, setLoggingOut] = useState(false);
// README §8.7 draws TWO separate mobile screens (`#1d` 我的 hub, `#1e` 设置 sub-list) that don't
// map 1:1 onto the desktop directory -- this is which of the two the bare `/settings` mobile route
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
const [mobileSubView, setMobileSubView] = useState(false);
const logout = async () => {
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
setLoggingOut(true);
// docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近
// 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空
// sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条
// 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。
await revokeServerSession().catch(() => {});
resetTinode();
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
// Deliberately NOT clearTenant() -- that clears useSessionStore's `tenant`, and LoginPage's own
// `if (!tenant) return <Navigate to="/tenant" replace />` immediately bounces the navigate('/login')
// below straight back to the tenant-code entry screen instead of this tenant's actual login page
// (confirmed live: this used to be a real dead end, not a hypothetical). A plain logout should
// stay on the same enterprise's login screen, ready for the same or a different user to sign back
// in -- only the explicit "切换企业" link is supposed to clear the tenant.
//
// The QueryClient instance is module-scoped for the whole SPA lifetime (App.tsx) and survives a
// client-side route change -- without this, a second account logging in on the same tab/device
// (a shared kiosk machine, realistic for an enterprise client) would briefly render the FIRST
// account's still-cached profile/privacy/blocks data before the background refetch resolves.
queryClient.clear();
navigate('/login');
};
// Below the desktop breakpoint there's no room to dock both columns (confirmed live: without
// this, the 260px directory and the content column both try to render side by side on a phone-
// width viewport, squeezing the content into a sliver narrow enough that its own text wraps
// one character per line). Mirrors ContactsLayout's exact `showListOnNarrow` pattern: show the
// directory only at the bare `/settings` index, content only once a specific section is picked.
const showListOnNarrow = location.pathname === '/settings';
return (
<div className="flex h-full w-full">
<aside className={`h-full w-full flex-none flex-col bg-white md:flex md:w-[260px] md:border-r md:border-border-panel ${showListOnNarrow ? 'flex' : 'hidden'}`}>
{/* Desktop (README §8.1): one directory, matches ≥md exactly as before. */}
<div className="hidden h-full flex-1 flex-col md:flex">
<header className="flex h-15 flex-none items-center px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
</header>
<div className="flex items-center gap-3 border-b border-border-row px-5 pb-4.5 pt-2">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
avatarFileId={meQuery.data?.profile.avatar_file_id}
className="h-12 w-12 flex-none rounded-xl text-base"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-semibold text-text-body">{meQuery.data?.profile.nickname}</strong>
<span className="truncate text-xs text-text-tertiary-2">ID:{meQuery.data?.profile.public_account}</span>
</span>
</div>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
{DIRECTORY.map((item) => {
const active = location.pathname.startsWith(item.to);
return (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm no-underline ${
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={item.icon} className="text-[20px]" />
<span className="flex-1">{item.label}</span>
</Link>
);
})}
</div>
<button
type="button"
onClick={logout}
disabled={loggingOut}
className="mx-5 mt-auto mb-5 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
>
退出登录
</button>
</div>
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory:
tappable profile card (-> /settings/profile), 我的收藏/通话记录 (this app's only way to
reach those two on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then
账号与安全/隐私 direct, and "设置" as its own second-level list rather than a 4th direct
link -- these two screens genuinely don't share one flat structure in the mock. */}
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>
<Link to="/settings/profile" className="flex items-center gap-3.5 bg-white px-5 py-5 no-underline">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
avatarFileId={meQuery.data?.profile.avatar_file_id}
className="h-16 w-16 flex-none rounded-2xl text-2xl"
/>
<strong className="flex-1 truncate text-[19px] font-semibold text-text-heading">{meQuery.data?.profile.nickname}</strong>
<Icon name="qr_code_2" className="flex-none text-xl text-text-tertiary-3" />
<Icon name="chevron_right" className="flex-none text-xl text-icon-arrow" />
</Link>
<div className="flex flex-col gap-2.5 py-2.5">
<div className="flex flex-col bg-white">
{MOBILE_SHORTCUTS.map((item, i) => (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-3 px-5 py-3.5 text-base text-text-body no-underline ${i < MOBILE_SHORTCUTS.length - 1 ? 'border-b border-border-list-item' : ''}`}
>
<Icon name={item.icon} className={`text-xl ${item.colorClass}`} />
<span className="flex-1">{item.label}</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
))}
</div>
<div className="flex flex-col bg-white">
<Link to="/settings/security" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<Icon name="security" className="text-xl text-[#7c5cf6]" />
<span className="flex-1">账号与安全</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<Link to="/settings/privacy" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<Icon name="lock" className="text-xl text-[#e2537a]" />
<span className="flex-1">隐私</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<button type="button" onClick={() => setMobileSubView(true)} className="flex items-center gap-3 px-5 py-3.5 text-left text-base text-text-body">
<Icon name="settings" className="text-xl text-text-tertiary-3" />
<span className="flex-1">设置</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</button>
</div>
</div>
</div>
{/* Mobile "设置" sub-list (mock `07/1e`) -- a second, DIFFERENT grouping from both the hub
above and the desktop directory: 清空缓存/意见反馈 sit here directly, not nested under
通用. 清空缓存 links through to 通用 rather than duplicating that action (and its
already-established "no fake byte count" honesty) in a second place; 意见反馈 has no
generic post-login feedback endpoint anywhere in this protocol (the only feedback API,
`/tenant-entry-feedback`, is unauthenticated and pre-login-only per its own name) so this
is a real, recorded backend gap, not a missed wire-up. 关于 MeetingIM removed entirely per
product decision -- its only real content was a version string and an update-check the
backend rejects for `platform=web`, not worth a dedicated page. */}
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'flex' : 'hidden'}`}>
<div className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center bg-white px-1">
<button type="button" onClick={() => setMobileSubView(false)} className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</button>
<strong className="text-center text-[17px] font-semibold text-text-heading">设置</strong>
<span />
</div>
<div className="flex flex-col gap-2.5 py-2.5">
<div className="flex flex-col bg-white">
{DIRECTORY.filter((item) => item.key !== 'profile').map((item, i, arr) => (
<Link
key={item.key}
to={item.to}
className={`flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline ${i < arr.length - 1 ? 'border-b border-border-list-item' : ''}`}
>
<span>{item.label}</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
))}
</div>
<div className="flex flex-col bg-white">
<Link to="/settings/general" className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<span>清空缓存</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<button type="button" onClick={notImplemented} className="flex items-center justify-between px-5 py-3.5 text-left text-base text-text-body">
<span>意见反馈</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</button>
</div>
<button type="button" onClick={logout} disabled={loggingOut} className="bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50">
退出登录
</button>
</div>
</div>
</aside>
<div className={`h-full min-w-0 flex-1 flex-col overflow-y-auto bg-[#f6f7f9] md:flex ${showListOnNarrow ? 'hidden' : 'flex'}`}>
<Link to="/settings" className="flex h-11 flex-none items-center gap-1 px-4 text-sm text-text-secondary no-underline md:hidden">
<Icon name="arrow_back" className="text-[18px]" />
设置
</Link>
<Outlet />
</div>
</div>
);
}
@@ -1,337 +0,0 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { acceptGroupInvitation } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
import { formatEventTimestamp } from '@/lib/messages/time';
/**
* Ported verbatim from the old app's message-center-view.jsx (lines ~420-455) -- the backend writes
* raw event codes into `summary` in several real cases instead of human text (confirmed live,
* docs/IM-后端问题清单.md 问题30), so this maps event/summary codes to display text rather than
* trusting `summary` blindly. Lookup order: NOTIFICATION_TEXT[summary] first (covers the bare
* "accepted"/"rejected"/"continue"/"cancel" codes), then NOTIFICATION_TEXT[event_type], then the
* raw summary (covers genuinely human text like a meeting title or report date), then a generic
* fallback -- never silently show nothing.
*/
const NOTIFICATION_TEXT: Record<string, string> = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.announcement.updated': '群公告有更新',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
'group.mute.updated': '群禁言设置有变更',
'group.owner.transferred': '群主已转让',
'group.role.updated': '群成员角色有变更',
'group.settings.updated': '群设置有更新',
'group.join_request.accepted': '你的入群申请已通过',
'group.join_request.rejected': '你的入群申请被拒绝',
accepted: '对方已通过你的好友申请',
rejected: '对方拒绝了你的好友申请',
continue: '你确认继续了一次安全风险提示',
cancel: '你取消了一次风险操作确认',
};
function notificationText(item: SystemNotification): string {
return NOTIFICATION_TEXT[item.summary] || NOTIFICATION_TEXT[item.event_type] || item.summary || '您有一条新的系统通知。';
}
/** icon/color per event family -- matches the design's 新设备登录/好友申请/群聊动态/版本更新
* categories, generalized by event_type prefix since the real event set is broader than those 4. */
function iconForEvent(eventType: string): { icon: string; className: string } {
if (eventType.startsWith('friend.')) return { icon: 'person_add', className: 'bg-accent-soft-bg text-accent' };
if (eventType.startsWith('group.')) return { icon: 'groups', className: 'bg-success-bg text-success' };
if (eventType.startsWith('security.')) return { icon: 'gpp_maybe', className: 'bg-error-strip-bg text-unread' };
if (eventType.startsWith('meeting.')) return { icon: 'event', className: 'bg-warn-bg text-warn' };
return { icon: 'notifications', className: 'bg-warn-bg text-warn' };
}
/**
* Routes on `event_type`, NOT `action.type` -- confirmed live (docs/IM-后端问题清单.md 问题30) a
* real friend-request notification's `action` is just `{"user_id":"..."}`, no `type` key at all,
* contradicting this doc's own worked example. `action.device`/`action.ip` (new-device-login) are
* typed but confirmed NOT populated by any real response yet (新增12, still open) -- this simply
* won't render a link for that case until the backend actually implements it, rather than building
* a rich device/IP card around data that can't exist yet.
*/
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
// Handled separately (see the card's own `action.type === 'group_invitation_accept'` branch below,
// rendering a real "接受邀请" button) -- NOT a plain navigation like every other group.* event.
// You are not yet a member of this group at the point this notification arrives, so navigating
// straight to `/messages/{topic}` (what the branch below would otherwise do, since this event also
// carries a real `grp...` action.topic) would open a conversation you have no access to.
if (item.action?.type === 'group_invitation_accept') return null;
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
}
if (item.action?.device || item.action?.ip) return { label: '前往安全设置', to: '/settings/security' };
return null;
}
/** README §9.1 (four-column layout, no detail rail). No "mark all read" endpoint exists anywhere
* in the protocol doc (confirmed via a research pass) -- "全部标为已读" loops the single-item
* `POST .../read` over every currently-unread item instead of a batch call that doesn't exist. */
export function SystemMessagesView() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const bootstrap = useBootstrap();
const query = useInfiniteQuery({
queryKey: ['system-notifications'],
queryFn: ({ pageParam }) => getSystemNotifications(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
const unread = items.filter((i) => !i.read_at);
const [error, setError] = useState('');
const readMutation = useMutation({
mutationFn: (id: string) => markSystemNotificationRead(id),
onSuccess: (data, id) => {
queryClient.setQueryData(['system-notifications'], (prev: typeof query.data) =>
prev
? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: p.items.map((i) => (i.id === id ? { ...i, read_at: data.read_at } : i)) })) }
: prev,
);
// AppShell's own NavRail/BottomTabBar unread badge trusts `bootstrap.entry_badges.
// system_unread_count` as its ONLY source for this topic (deliberately excluded from the
// live per-conversation sum) -- but that query is `staleTime: Infinity` and nothing anywhere
// else invalidates it, so without this the badge would never decrease within a session no
// matter how many notifications get marked read here.
queryClient.invalidateQueries({ queryKey: ['bootstrap'] });
},
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
});
// README/docs :1464/1739's `group.invitation.created` flow -- the doc marks the accept endpoint
// "待实现" but it's real (see acceptGroupInvitation's own comment). Success takes you straight to
// the group (you're a member now); failure is surfaced inline on the card itself (an expired/
// already-handled invitation, or a race where someone else's action beat you to it), not just a
// toast, so it doesn't look like the button silently did nothing.
const [invitationError, setInvitationError] = useState<Record<string, string>>({});
// Real, reported bug: this card's "接受邀请" button stayed live and clickable forever, even for an
// invitation already accepted (in an earlier visit to this same list) or whose group had since been
// dissolved -- there was no concept of "this one's done" anywhere, on success OR failure, so every
// render just re-derived "show the button" from the notification's own static `action` payload,
// which the server never updates after the fact. Once EITHER outcome happens for a given
// invitation_id, it should never look freshly-clickable again -- a genuinely transient failure
// (a network blip) is rare enough, and "already handled"/"group gone" common enough, that treating
// every failure as terminal is the safer default. Doesn't survive a page reload (the server's own
// notification record has no resolved flag to read back), only this list staying mounted -- a real,
// acknowledged gap, not silently pretended away.
const [resolvedInvitations, setResolvedInvitations] = useState<Record<string, 'accepted' | 'failed'>>({});
const acceptInvitationMutation = useMutation({
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
onSuccess: (data, invitationId) => {
setInvitationError((prev) => {
if (!prev[invitationId]) return prev;
const next = { ...prev };
delete next[invitationId];
return next;
});
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' }));
navigate(`/messages/${data.topic}`);
},
onError: (err, invitationId) => {
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'failed' }));
},
});
const [markingAll, setMarkingAll] = useState(false);
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
// used to leave any unread notification on a page the user never scrolled to untouched, while the
// button itself still disappeared (since it's gated on the loaded-only `unread.length`), falsely
// implying everything had been cleared. Pages through every remaining page first, then marks the
// now-complete set, so "全部" is actually all of it.
const markAllRead = async () => {
setMarkingAll(true);
try {
// `query` is a snapshot from THIS render -- fetchNextPage() updates the cache and causes a
// re-render with a NEW query object, but this closure keeps holding the old one, so its
// `.hasNextPage` never changes and this loop would never exit for any account with more than
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
// of trusting a captured object's field is what actually terminates.
// `fetchNextPage()` swallows its own request failures (TanStack Query v5's QueryObserver
// catches internally rather than rejecting) -- a page that fails to load leaves `next_cursor`
// exactly where it was, so a plain `while (cursor)` would retry the SAME stuck page forever on
// any transient failure (expired token, network blip), leaving `markingAll` stuck true and
// this button permanently showing "标记中…" with nothing telling the user why. Capped so a
// failure surfaces as an error instead of hanging.
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
let guard = 0;
while (cursor && guard++ < 200) {
await query.fetchNextPage();
const next = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
if (next === cursor) {
setError('翻页失败,请重试');
return;
}
cursor = next;
}
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
// it the same as mutate) -- this swallow only stops Promise.all's fail-fast rejection from
// ALSO becoming an unhandled rejection on top of that, it doesn't hide the error itself.
await Promise.all(allItems.filter((i) => !i.read_at).map((i) => readMutation.mutateAsync(i.id))).catch(() => {});
} finally {
setMarkingAll(false);
}
};
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-[#eef1f4]">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<div className="flex items-center gap-3">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<span className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-warn-bg text-warn">
<Icon name="notifications" className="text-xl" />
</span>
<div className="flex flex-col">
<strong className="text-[15px] font-semibold text-text-heading">系统消息</strong>
<span className="text-xs text-text-tertiary-3">由 迅工捷使 安全团队发送,不可回复</span>
</div>
</div>
{/* `unread` only reflects pages already loaded into this view, but `markAllRead` itself
correctly walks every remaining page (see its own comment below) -- gating visibility on
`unread.length` alone defeats that fix by hiding the button the moment the FIRST page
happens to show zero unread, even while an older unread notification sits unreached on a
later page. `bootstrap.entry_badges.system_unread_count` is the same authoritative,
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
whenever it's available. */}
{/* `??` only guards against a MISSING bootstrap count, not a STALE one -- bootstrap is
`staleTime: Infinity` and only ever invalidated in the "count goes down" direction
(readMutation's onSuccess below), so a cached `0` from before any new notification
arrived would otherwise out-vote a real non-empty `unread` this render actually has. */}
{Math.max(bootstrap.data?.entry_badges?.system_unread_count ?? 0, unread.length) ? (
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
{markingAll ? '标记中…' : '全部标为已读'}
</button>
) : null}
</header>
<div className="flex flex-col items-center gap-3 p-6">
{query.isLoading ? (
<div className="py-10 text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-sm text-text-tertiary-4">暂无系统消息</div>
) : (
items.map((item) => {
const { icon, className } = iconForEvent(item.event_type);
const action = actionRoute(item);
const isUnread = !item.read_at;
// The whole card is the click target, not just the "查看…" link below -- most
// notifications (好友申请已通过/拒绝, most 群聊动态) have no `actionRoute` at all, so
// gating mark-read on that inner link left them with literally nothing clickable
// anywhere on the card, permanently stuck showing "新" no matter how long they sat there
// (a real, reported bug: "有些消息没有链接,没地方点击就一直是新"). A card with an action
// still marks read AND navigates; one without just marks read, since there's nowhere to go.
const activate = () => {
if (isUnread) readMutation.mutate(item.id);
if (action) navigate(action.to);
};
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={activate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
}}
className={`w-full flex-none cursor-pointer rounded-xl bg-white p-[18px] text-left md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}
>
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.created_at))}</div>
<div className="mt-2 flex items-start gap-3">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg ${className}`}>
<Icon name={icon} className="text-xl" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1.5">
<strong className="flex-1 text-[15px] font-semibold text-text-heading">{item.title}</strong>
{isUnread ? <b className="flex-none rounded bg-unread px-1 text-[10px] leading-[14px] text-white">新</b> : null}
</div>
<p className="m-0 text-sm text-[#4a4f57]">{notificationText(item)}</p>
{/* Plain text, not a nested `<button>` -- it's inside the card's own click target
above, and a button-in-button (role="button" div counts the same way) is the
same illegal-nesting/double-fire shape already fixed once in this codebase for
favorites' voice player. */}
{action ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null}
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
<div className="mt-1 flex flex-col items-start gap-1.5">
{resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
<span className="text-xs text-text-tertiary-4">已加入群聊</span>
) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
<span className="text-xs text-text-tertiary-4">邀请已失效</span>
) : (
// A REAL nested button, unlike the plain-text action label above -- accepting
// is a distinct action from "open this card" (which only marks it read; see
// actionRoute's own comment for why this event type has no plain navigation
// at all), so stopPropagation is the right tool here rather than avoiding the
// nesting the way the plain-text case does.
<button
type="button"
disabled={acceptInvitationMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (isUnread) readMutation.mutate(item.id);
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
}}
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
? '处理中…'
: '接受邀请'}
</button>
)}
{invitationError[item.action.invitation_id] ? (
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
) : null}
</div>
) : null}
</div>
</div>
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
other categories' cards (好友申请/群聊动态/版本更新) have none. */}
{item.event_type.startsWith('security.') ? (
<footer className="mt-3 border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">保持警惕 · 迅工捷使 安全团队</footer>
) : null}
</div>
);
})
)}
{query.hasNextPage ? (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="py-2 text-sm text-accent disabled:opacity-60"
>
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
-167
View File
@@ -1,167 +0,0 @@
@import "tailwindcss";
/*
* Material Icons' OWN Google Fonts stylesheet (index.html's `<link>`) ships its `.material-icons`
* class completely unlayered -- plain CSS, no `@layer`. Tailwind v4 (the `@import` above) puts every
* one of its own utilities inside `@layer utilities`. Per the CSS Cascade Layers spec, ANY unlayered
* declaration beats EVERY layered one for the same property on the same element, regardless of
* selector specificity or which stylesheet loads later in the document -- confirmed live: an
* `<Icon className="text-[14px]">` computed to `font-size: 24px` regardless of the requested size,
* every time, because Google's unlayered rule was always winning over Tailwind's layered
* `.text-\[14px\]` utility. Icon.tsx uses its own `im-icon` class (not `.material-icons`) specifically
* so Google's competing rule never matches these elements at all -- this is the same visual default
* (24px, line-height 1, etc., copied 1:1 from Google's own stylesheet), just declared inside `@layer
* base` so Tailwind's `@layer utilities` (which Tailwind itself registers after `base`) can actually
* override it, the way every call site sizing an icon via `text-[Npx]` already assumed it could.
*/
@layer base {
.im-icon {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
}
/*
* Design tokens ported 1:1 from docs/design_handoff_meetingim_web/README.md's "Design Tokens"
* section. Values are exact hex/px from the design handoff, not semantic guesses -- keep them in
* sync with that doc, not with whatever the previous im_webapp's css/base.css happened to use.
*
* Breakpoints are intentionally remapped onto Tailwind's own sm/md/lg so every component can just
* use the standard `sm:`/`md:`/`lg:` prefixes (min-width, mobile-first) instead of inventing custom
* variants: <640 base styles = single column mobile, sm: (>=640) = two column, md: (>=960) = three
* column, lg: (>=1280) = four column docked detail panel.
*/
@theme {
--breakpoint-sm: 640px;
--breakpoint-md: 960px;
--breakpoint-lg: 1280px;
--font-sans: Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
/* Primary / accent */
--color-accent: #1677ff;
--color-accent-soft-bg: #e3eefe;
--color-accent-selected-bg: #e8f1fe;
--color-focus-border: #4091ff;
--color-btn: #368bfa;
--color-btn-hover: #1677e8;
--color-btn-disabled: #b8d5fa;
/* Sent bubble */
--color-bubble-mine: #b9eb8f;
/* Surfaces */
--color-surface-chat: #eef1f4;
--color-surface-content: #f6f7f9;
--color-surface-page: #f7f9fc;
--color-surface-rail: #f2f4f7;
--color-surface-input: #f2f4f7;
--color-surface-search: #f1f2f4;
/* Borders */
--color-border-panel: #eceef1;
--color-border-row: #f1f2f4;
--color-border-list-item: #f4f5f7;
/* Text */
--color-text-title: #14171c;
--color-text-heading: #22262d;
--color-text-body: #252931;
--color-text-secondary: #656b74;
--color-text-tertiary-1: #4a4f57;
--color-text-tertiary-2: #969ca5;
--color-text-tertiary-3: #9ca2aa;
--color-text-tertiary-4: #8d949d;
--color-text-placeholder-1: #b2b7bf;
--color-text-placeholder-2: #a5abb4;
--color-icon-default: #555c65;
--color-icon-arrow: #c2c7ce;
/* Status */
--color-unread: #f34b46;
--color-danger-text: #d94b4b;
--color-danger-border: #cf3f3f;
--color-danger-border-soft: #f3c4c4;
--color-online: #1fb36a;
--color-offline: #aeb4bd;
/* Orange (system messages / group owner / warning) */
--color-warn: #ff951c;
--color-warn-bg: #fff5e5;
--color-announce: #f5a623;
--color-announce-bg: #fff8e6;
--color-announce-text: #6d4c41;
/* Green (file assistant / success) */
--color-success: #16ae54;
--color-success-bg: #eefaf3;
/* Alert strips */
--color-error-strip-bg: #ffeef0;
--color-error-strip-text: #b51c1c;
--color-info-strip-bg: #e8f1fe;
--color-info-strip-text: #1a4f9c;
/* Call surfaces (dark) */
--color-call-bg-1: #1b2231;
--color-call-bg-2: #1f2a3d;
--color-call-bg-3: #3b4a63;
/* Avatar palette (deterministic per-contact hashing picks from this list) */
--color-avatar-1: #3c8df6;
--color-avatar-2: #7c5cf6;
--color-avatar-3: #2bb673;
--color-avatar-4: #f28c3b;
--color-avatar-5: #e2537a;
--color-avatar-6: #0097a8;
--color-avatar-7: #8d6e63;
--color-avatar-8: #5c6bc0;
--color-avatar-9: #26a69a;
--color-avatar-10: #ef6c00;
--color-avatar-11: #9ca2aa;
/* Shadows */
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
/* screens/01-account.dc.html's 50px auth CTAs (登录/注册/提交...). */
--shadow-btn: 0 6px 16px rgba(54, 139, 250, 0.2);
/* screens/02-messages.dc.html's 36px 发送 button (ConversationView/VoiceRecorder) -- a smaller
button gets a smaller shadow, not the same token as the 50px auth buttons above. */
--shadow-btn-sm: 0 4px 12px rgba(54, 139, 250, 0.2);
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
}
/* MessageRow's jump-to-message flash (ConversationView, 查找聊天记录 result click) -- a brief
background pulse marking the message the user asked to find, fading back to transparent so it
doesn't look like a permanent selection state. */
@keyframes jump-highlight {
0%,
30% {
background-color: var(--color-accent-soft-bg);
}
100% {
background-color: transparent;
}
}
body {
margin: 0;
background: var(--color-surface-page);
color: var(--color-text-body);
font-family: var(--font-sans);
}
-63
View File
@@ -1,63 +0,0 @@
import { imPost, imRequest } from './client';
/**
* docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/
* 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个
* 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。
* 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次
* 调用失败而跳过后续的本地清理。
*/
export function logout() {
return imPost<void>('/auth/logout');
}
export interface VerificationCodeResponse {
challenge_id: string;
resend_after_seconds: number;
}
export function requestVerificationCode(scene: 'reset_password' | 'register', phone: string, tenantCode?: string) {
return imPost<VerificationCodeResponse>(
'/auth/verification-codes',
scene === 'register' ? { scene, phone, registration_type: 0, tenant_code: tenantCode } : { scene, phone },
{ auth: false },
);
}
export interface RegistrationVerifyResponse {
verification_token?: string;
verification_mode?: 'invite_code';
}
export function verifyRegistrationCode(challengeId: string, code: string) {
return imPost<RegistrationVerifyResponse>(
`/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`,
{ code },
{ auth: false },
);
}
export interface PasswordResetVerifyResponse {
reset_token: string;
}
/**
* Password reset verifies through an entirely different endpoint family
* (`/auth/password-reset/*`, not `/auth/verification-codes/*`) even though it shares the same
* `/auth/verification-codes` code-request endpoint above via `scene`. No Idempotency-Key -- a plain
* code check, not a mutation.
*/
export function verifyPasswordResetCode(challengeId: string, code: string) {
return imRequest<PasswordResetVerifyResponse>(
`/auth/password-reset/challenges/${encodeURIComponent(challengeId)}/verify`,
{ method: 'POST', auth: false, body: { code } },
);
}
export function confirmPasswordReset(resetToken: string, newPassword: string) {
return imPost<void>(
'/auth/password-reset/confirm',
{ reset_token: resetToken, new_password: newPassword },
{ auth: false },
);
}
-49
View File
@@ -1,49 +0,0 @@
import { imGet } from './client';
export interface MessagePolicy {
supported_actions?: string[];
edit_window_seconds?: number;
recall_window_seconds?: number;
}
/** README §2.1's "固定置顶两项" -- topic names are fixed/well-known on this backend (confirmed:
* docs/IM-前后端对接文档.md:909, `file_assistant.topic:"slf"`, `system_messages.topic:"sys"`), not
* per-user values the client has to discover another way. */
interface BootstrapEntry {
enabled: boolean;
route: string;
topic?: string;
unread_count: number;
/** e.g. "topic" (file_assistant) vs "business" (system_messages) -- which side actually owns the
* unread count above, confirmed live but not consumed by anything yet. */
unread_source?: string;
}
export interface BootstrapConfig {
message_policy?: MessagePolicy;
limits?: { max_forward_targets?: number; [key: string]: unknown };
features?: Record<string, boolean>;
permissions?: Record<string, boolean>;
modules?: unknown;
workbench_version?: unknown;
entries?: {
new_friends?: BootstrapEntry;
groups?: BootstrapEntry;
file_assistant?: BootstrapEntry;
system_messages?: BootstrapEntry;
};
entry_badges?: {
new_friends_pending_count?: number;
groups_unread_count?: number;
file_assistant_unread_count?: number;
system_unread_count?: number;
workbench_total_unread?: number;
contacts_total_unread?: number;
};
system_topic?: { topic: string; type: string };
[key: string]: unknown;
}
export function getBootstrap() {
return imGet<BootstrapConfig>('/bootstrap');
}
-66
View File
@@ -1,66 +0,0 @@
// Call REST endpoints (docs/IM-前后端对接文档.md §8.12.1, lines 2066-2076). Confirmed live
// (2026-09-08): `POST /v1/im/calls/rtc-join` is real and processes requests meaningfully (a probe
// with a made-up call_id returned `404 CALL_NOT_FOUND`, not a generic route-404 or a
// field-validation error) -- this is the Agora/声网 RTC path the doc's own closing paragraph
// describes for "新客户端" (§8.12.1), NOT the raw-WebRTC/SDP-over-{note} path §0.5.4.7 and §8.1-8.11
// otherwise describe throughout -- the old app's own real, shipped call code corroborates that the
// raw-WebRTC path is the one that's actually broken in production (its own comments say the
// call-invite pub is gated behind a feature flag that currently rejects it). The initial invite
// still goes through the SAME `{pub}` message as the raw-WebRTC story (confirmed by the doc's own
// sentence: "新客户端在发起方收到 ctrl.params.seq 后...分别调用 rtc-join" -- i.e. rtc-join happens
// AFTER and IN ADDITION TO the pub-based invite, not instead of it) -- see send.ts's call-invite
// helper. Ringing/accept/hang-up stay on Tinode `{note}` (lightweight state signals, not media
// negotiation); only the SDP/ICE exchange is replaced by Agora's own SDK doing media transport.
import { imGet, imPost } from './client';
export interface RtcJoinResult {
provider: string;
auth_mode: string;
app_id: string;
channel_name: string;
uid: string;
token: string;
token_expires_at: string;
call_id: string;
topic: string;
seq: number;
call_type: 'audio' | 'video';
role: 'caller' | 'callee';
}
export function joinRtcCall(topic: string, callId: string, seq: number, callType: 'audio' | 'video', role: 'caller' | 'callee') {
return imPost<RtcJoinResult>('/calls/rtc-join', { topic, call_id: callId, seq, call_type: callType, role });
}
/**
* Confirmed live (2026-09-08, real call `75728`) -- completely different from the doc's own minimal
* example: `direction` is a string enum (`"outgoing"`/presumably `"incoming"`), not an `incoming`
* boolean; there's no `peer` object at all, just a flat `peer_user_id` (a real `usr...` id, NOT the
* `p2p...`-hashed form `topic` itself comes back as -- so unlike favorites.ts's `source.topic`, THIS
* field can be resolved to a display name via the chat list); and there are four separate
* timestamps (`created_at`/`started_at`/`finished_at`/`updated_at`), no single `ts`.
*/
export interface CallHistoryItem {
topic: string;
seq: number;
call_id: string;
call_type: 'audio' | 'video';
direction: 'incoming' | 'outgoing' | string;
status: string;
result?: string;
end_reason?: string;
duration_ms: number;
caller_id: string;
callee_id: string;
peer_user_id: string;
created_at: string;
started_at?: string;
finished_at?: string;
updated_at?: string;
}
export function getCallHistory(cursor = '', limit = 20) {
const qs = new URLSearchParams({ limit: String(limit) });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls?${qs.toString()}`);
}
-276
View File
@@ -1,276 +0,0 @@
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS
// protocol. Ported from the previous im_webapp's src/lib/im-api.js -- the header names, the
// reason-code translation table, and the batch-endpoint "always 200, real result in items[]"
// quirk are all hard-won facts about this exact backend (im_team/im_server, server/im_http.go),
// verified against a live deployment over months of integration work. They are not guesses and
// should not be "cleaned up" back to what the upstream Tinode docs say (this deployment's header
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
// a real, repeatedly-rediscovered bug in the old codebase).
import { API_KEY } from '@/lib/config';
import { getKickedInfo } from '@/lib/tinode/connection';
import { useSessionStore } from '@/stores/session';
const DEFAULT_API_BASE = '/v1/im';
const DEFAULT_FILE_BASE = '/v0/file';
const CONFIGURED_FILE_BASE = import.meta.env.VITE_TINODE_FILE_BASE?.trim();
export class ImApiError extends Error {
reason?: string;
status?: number;
details?: unknown;
constructor(message: string, reason?: string, status?: number, details?: unknown) {
super(message);
this.reason = reason;
this.status = status;
this.details = details;
}
}
/** docs/IM-前后端对接文档.md §12.4's full reason list -- see client.ts's header comment. */
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
ACCOUNT_DISABLED: '账号已被禁用',
RATE_LIMITED: '操作过于频繁,请稍后再试',
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
PERMISSION_DENIED: '没有权限执行此操作',
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
// internal message or the bare reason string straight to the user for this exact reason code,
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
FORBIDDEN: '没有权限执行此操作',
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
AUTH_REQUIRED: '请先登录',
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
VERIFICATION_RATE_LIMITED: (details) =>
isRetryAfter(details) ? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试` : '操作过于频繁,请稍后再试',
ALREADY_GROUP_MEMBER: '对方已经是群成员',
DESCRIPTION_REQUIRED: '请填写简介内容',
DESCRIPTION_TOO_LONG: '简介内容过长',
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
INVITATION_EXPIRED: '邀请已过期',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
QR_CODE_INVALID: '二维码无效,请重新扫描',
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
// friends, or a request between the two of you (either direction) is already pending.
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
USER_NOT_DISCOVERABLE: '未找到该用户',
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
// verification-code verify) -- keep generic enough to be accurate for both.
CODE_INVALID: '验证码或独立密码不正确',
TOO_MANY_ATTEMPTS: (details) =>
isRetryAfter(details) ? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试` : '连续输错次数过多,请稍后重试',
};
function isRetryAfter(details: unknown): details is { retry_after_seconds: number } {
return !!details && typeof details === 'object' && 'retry_after_seconds' in details;
}
function translateReason(reason: string | undefined, details?: unknown): string | null {
if (!reason) return null;
const entry = REASON_MESSAGES[reason];
return (typeof entry === 'function' ? entry(details) : entry) || null;
}
export function requestId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `web_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
export function apiBase(): string {
const endpoints = useSessionStore.getState().tenant?.endpoints;
return (endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
}
export function fileBase(): string {
const endpoints = useSessionStore.getState().tenant?.endpoints;
// Production may expose the web UI and the IM API through different deployments. Prefer the
// build-time canonical file origin so a tenant context previously resolved through the web host
// cannot keep routing attachments to that host's stale /v0/file proxy. Local development leaves
// this unset and continues to use the resolved relative endpoint through Vite's proxy.
return (CONFIGURED_FILE_BASE || endpoints?.file || DEFAULT_FILE_BASE).replace(/\/$/, '');
}
export function currentTenantCode(): string {
return useSessionStore.getState().tenant?.code || 'default';
}
/** Set by tinode-web.jsx-equivalent login/reconnect code; tab-lifetime, independent of "remember me". */
export function authToken(): string {
return sessionStorage.getItem('im-session-token') || '';
}
function normalizeResponse(payload: { code?: number; data?: unknown }): unknown {
return payload && payload.code === 0 && payload.data !== undefined ? payload.data : payload;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
headers?: Record<string, string>;
auth?: boolean;
tenantCodeOverride?: string;
}
/** Core request helper. `auth: false` skips the Authorization header (pre-login calls). */
export async function imRequest<T = unknown>(
path: string,
{ method = 'GET', body, headers, auth = true, tenantCodeOverride }: RequestOptions = {},
): Promise<T> {
const finalHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'X-MeetingIM-APIKey': API_KEY,
'X-IM-Schema': '1',
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
'X-Request-ID': requestId(),
...headers,
};
if (auth) {
const token = authToken();
if (token) finalHeaders.Authorization = `Bearer ${token}`;
}
const response = await fetch(apiBase() + path, {
method,
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let payload: { code?: number; data?: unknown; reason?: string; message?: string; details?: unknown } = {};
if (text) {
try {
payload = JSON.parse(text);
} catch {
throw new ImApiError(
response.ok ? '接口返回非 JSON 数据' : response.statusText || '接口请求失败',
'NON_JSON_RESPONSE',
response.status,
text.slice(0, 160),
);
}
}
if (!response.ok || payload?.code) {
const known = translateReason(payload.reason, payload.details);
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
// else in this client uniformly handled this before, so a session invalidated mid-use just made
// every subsequent call fail the same way forever with no path back to a real login.
if (auth && response.status === 401 && authToken()) forceReauth();
throw new ImApiError(
known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason,
response.status,
payload.details,
);
}
return normalizeResponse(payload) as T;
}
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
* cache, any open modal's local state) resets from scratch rather than needing each of them to
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
* token expires) only navigates once. */
let reauthTriggered = false;
function forceReauth() {
if (reauthTriggered) return;
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
reauthTriggered = true;
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
// network timing, not something this code controls). Without this, a REST 401 that happens to
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
// that WS message (usually already in flight over an open connection, typically faster than a
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
// by far the common case) just navigate ~200ms later than before -- not perceptible.
window.setTimeout(() => {
if (getKickedInfo()) return;
window.location.href = '/login';
}, 200);
}
const idem = () => ({ 'Idempotency-Key': requestId() });
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
// caller that needs its own extra header can't accidentally drop the Idempotency-Key.
export const imGet = <T = unknown>(path: string, opts?: RequestOptions) => imRequest<T>(path, opts);
export const imPost = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'POST', body, ...opts, headers: { ...idem(), ...opts.headers } });
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'PATCH', body, ...opts, headers: { ...idem(), ...opts.headers } });
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'PUT', body, ...opts, headers: { ...idem(), ...opts.headers } });
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'DELETE', ...opts, headers: { ...idem(), ...opts.headers } });
/**
* List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to
* only ever fetch page 1 (e.g. "am I this group's owner/admin" going undetected because the real
* answer was on page 2). Empty/repeated next_cursor means done.
*/
// Guards against a server-side cursor bug (e.g. two values that cycle back and forth) turning this
// into an infinite fetch loop -- 200 pages at a 20-50 item page size is already far more than any
// realistic friends/blocks/search list, so hitting this is itself a signal something's wrong server-
// side, not a real user's data legitimately needing more.
const MAX_PAGES = 200;
export async function fetchAllPages<T>(
fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>,
startCursor = '',
): Promise<T[]> {
let cursor = startCursor;
let items: T[] = [];
for (let page = 0; page < MAX_PAGES; page++) {
const result = await fetchPage(cursor);
items = items.concat(result?.items || []);
const nextCursor = result?.next_cursor || '';
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return items;
}
-99
View File
@@ -1,99 +0,0 @@
// Favorites REST endpoints (docs/IM-前后端对接文档.md ch.7.1-7.8). `favoriteMessages` (the create
// side) already lives in messages.ts since it's called from a message's context menu, not from
// here -- this file is the read/manage side (list, detail, delete, batch-delete, forward).
import type { DraftyContent } from 'tinode-sdk';
import { imDelete, imGet, imPost } from './client';
export type FavoriteType = 'text' | 'image' | 'video' | 'voice' | 'file' | 'link';
/**
* `snapshot`'s shape is polymorphic per `type`. Confirmed live (2026-09-08, real favorite
* `fav_I2yn5Rdux0E`) that a `type:"text"` snapshot is NOT the doc's minimal `{text}` example (line
* 1933) -- it's essentially the whole original message: `{content, from, head, seq, text, topic,
* ts, type, version}`, i.e. `text`/`content` are duplicates of each other and `seq`/`topic`/`type`
* duplicate the outer `source`/`type` fields. Genuinely useful new field: `from` -- a real sender
* user id, WITHOUT its `usr` prefix (confirmed: my own id is `usrH5dvZ3ckWxo`, this field came back
* as `H5dvZ3ckWxo`) -- a per-field prefix-stripping quirk not seen on any other endpoint in this
* app, so re-adding `usr` before matching it against `contacts[].topic` elsewhere is a real but
* fragile workaround, not done here yet (see the plan file's Phase 5 findings).
*
* Confirmed live (2026-09-08, data-rich test account) that this SAME "whole original message"
* shape applies to EVERY type, not just `text` -- `image`/`video`/`voice`/`file` all come back as
* `{content, from, head, seq, text, topic, ts, type, version}` too, where `content` is a real Drafty
* object (never the doc's flat `snapshot.file_id`/`duration_ms`/`snapshot.file{...}` shape). The
* actual per-type media data lives nested in `content.ent[].data`, keyed by Drafty entity type
* (`tp`): `IM` (image), `VD` (video), `AU` (voice), `EX` (file). Voice/image/video carry their
* payload INLINE as base64 in `data.val` (confirmed by decoding the base64 header: voice -> `RIFF`
* WAV, image -> PNG signature, video's `val` -> WebM/EBML signature); video ALSO carries a separate
* inline base64 JPEG poster frame in `data.preview` (distinct from `val`, which is the actual video
* bytes) -- there is no separate thumbnail for image since the image IS the thumbnail. `file` has no
* `val` at all, only an external `ref` (`/v0/file/s/{id}`), matching how `EX` already works in real
* chat messages. The old flat `snapshot.file_id`/`thumbnail_file_id`/`duration_ms`/`file{...}` fields
* below are kept as a defensive fallback (never observed live, but the doc's own worked examples
* disagree with its prose here too -- see next paragraph -- so it's cheap insurance, not dead code).
*
* The doc's own worked examples also disagree with its OWN prose for `type:"file"`: §7.3's text
* says file snapshots nest under `snapshot.file{name,size,mime,file_id}`, but the worked JSON
* example (line 1936) has `file_id`/`name` FLAT on `snapshot` directly, no nesting -- both shapes
* are typed as optional so either renders, trusting the concrete example over the prose.
*/
export interface Favorite {
favorite_id: string;
type: FavoriteType;
snapshot: {
text?: string;
content?: DraftyContent | string;
from?: string;
matches?: Array<{ at: number; len: number }>;
thumbnail_file_id?: string;
file_id?: string;
name?: string;
size?: number;
mime?: string;
file?: { name: string; size: number; mime: string; file_id: string };
duration_ms?: number;
waveform?: number[];
url?: string;
title?: string;
summary?: string;
risk_level?: string;
};
source?: { topic: string; seq: number };
collected_at: string;
availability: 'available' | 'removed' | string;
permissions?: { can_copy?: boolean };
}
/** Pulls one Drafty entity's `data` out of a favorite's `snapshot.content` by entity type (`tp`) --
* see the `Favorite` doc comment above for why this, not the flat `snapshot.*` fields, is where the
* real per-type media data for `image`/`video`/`voice`/`file` actually lives. */
export function favoriteEntityData(content: Favorite['snapshot']['content'], tp: string): Record<string, unknown> | undefined {
if (!content || typeof content === 'string') return undefined;
return content.ent?.find((e) => e.tp === tp)?.data;
}
export function getFavorites(type: FavoriteType | 'all' = 'all', query = '', cursor = '') {
const qs = new URLSearchParams({ type });
if (query) qs.set('q', query);
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: Favorite[]; next_cursor?: string }>(`/favorites?${qs.toString()}`);
}
export function deleteFavorite(favoriteId: string) {
return imDelete<{ favorite_id: string; deleted: boolean }>(`/favorites/${encodeURIComponent(favoriteId)}`);
}
export function batchDeleteFavorites(favoriteIds: string[]) {
return imPost<{ items: Array<{ favorite_id: string; deleted: boolean }>; failed?: Array<{ favorite_id: string; reason: string }> }>(
'/favorites/batch-delete',
{ favorite_ids: favoriteIds },
);
}
export function forwardFavorites(favoriteIds: string[], targetTopics: string[], comment = '') {
return imPost<{
items: Array<{ favorite_id: string; target_topic: string; status: string; seq?: number }>;
failed?: Array<{ favorite_id: string; target_topic: string; reason: string }>;
}>('/favorites/forward', { favorite_ids: favoriteIds, target_topics: targetTopics, comment });
}
-27
View File
@@ -1,27 +0,0 @@
/**
* Older clients persisted attachment refs on the API host, for example
* `https://api.example.com/v0/file/s/...`. Fetching those URLs with the file
* auth headers makes the browser forward a CORS preflight through the OSS
* redirect. Route them through the tenant's configured file endpoint instead,
* matching the relative refs emitted by current clients.
*/
export function normalizeFileDownloadUrl(
refUrl: string,
apiEndpoint: string,
fileEndpoint: string,
pageOrigin: string,
): string {
try {
const source = new URL(refUrl, pageOrigin);
const api = new URL(apiEndpoint, pageOrigin);
if (source.origin !== api.origin || !source.pathname.startsWith('/v0/file/')) return refUrl;
const target = new URL(fileEndpoint, pageOrigin);
target.pathname = target.pathname.replace(/\/$/, '') + source.pathname.slice('/v0/file'.length);
target.search = source.search;
target.hash = source.hash;
return target.toString();
} catch {
return refUrl;
}
}
-204
View File
@@ -1,204 +0,0 @@
// Friend/conversation-settings/blocklist REST endpoints. Paths and payload shapes ported from the
// old app's src/lib/im-api.js (contact-management-view.jsx's real "聊天设置" panel, not the mock
// direct-message-view.jsx) -- verified there, not guessed.
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
/** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter
* itself), so the contacts list groups by THIS field, not a client-side first-character guess like
* CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */
export interface Friend {
user_id: string;
display_name?: string;
remark?: string;
avatar_file_id?: string;
initial?: string;
topic?: string;
relationship?: string;
online_visibility?: string;
capabilities?: { can_send?: boolean; can_call?: boolean };
[key: string]: unknown;
}
export function getFriends(query = '', cursor = '', initial = '') {
const qs = new URLSearchParams();
if (query) qs.set('query', query);
if (initial) qs.set('initial', initial);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
}
export function deleteFriend(userId: string) {
return imDelete<void>(`/friends/${encodeURIComponent(userId)}`);
}
export function updateFriendRemark(userId: string, remark: string) {
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
}
/** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's
* nothing in the response describing what UI affordance to show -- that's derived client-side
* from `relationship`/`can_add` instead), and `avatar_file_id` is present. */
export interface UserSearchResult {
user_id: string;
display_name: string;
avatar_file_id?: string;
public_account?: string;
// docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare
// "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'`
// comparison could ever match either real pending state).
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
can_add: boolean;
}
export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') {
const qs = new URLSearchParams({ q: query, type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
}
export function sendFriendRequest(targetUserId: string, message?: string, source = 'search', sourceTopic?: string) {
return imPost<{ request_id: string; topic: string; status: string }>('/friend-requests', {
target_user_id: targetUserId,
message,
source,
source_topic: sourceTopic,
});
}
/** `source` and `created_at` (doc line 1419's worked example) were missing from this file's
* original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at`
* is mentioned in the doc's prose but not its own worked example, kept optional either way. */
export interface FriendRequest {
request_id: string;
applicant_id: string;
target_user_id: string;
direction: 'inbound' | 'outbound';
status: string;
message?: string;
source?: string;
created_at: string;
handled_at?: string;
peer?: { user_id: string; display_name?: string };
topic?: string;
}
/** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the
* endpoint with no filters at all, whose default direction/status scope isn't documented; the
* design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together
* (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on
* whatever an unfiltered call happens to return. */
export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') {
const qs = new URLSearchParams({ direction, status });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`);
}
export function acceptFriendRequest(requestId: string, version?: number) {
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/accept`, version !== undefined ? { version } : {});
}
export function rejectFriendRequest(requestId: string, version?: number) {
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {});
}
/**
* Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the
* doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess.
* `remark` was also confirmed absent from a real friend's response even though the doc's worked
* example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile.
* display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap
* between the doc and what this deployment actually returns right now.
*/
export interface UserProfile {
user_id: string;
display_name: string;
remark?: string;
avatar_file_id?: string;
gender?: string;
region?: string;
public_account?: string;
signature?: string;
// Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific
// pending states, never a bare "pending".
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
topic?: string;
capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean };
}
/** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's
* own user_id (Tinode convention), so DetailColumn can pass `topicId` straight through. */
export function getUserProfile(identifier: string) {
return imGet<UserProfile>(`/users/${encodeURIComponent(identifier)}/profile`);
}
export interface ConversationSettings {
pinned: boolean;
mute_notifications: boolean;
version: number;
}
/** P2P-only -- group pin/mute goes through a different, group-specific mechanism (native Tinode private/tags). */
export function getConversationSettings(topic: string) {
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`);
}
/**
* P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on
* the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities.
* can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28):
* sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试"
* with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can
* never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line
* 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows,
* so there's no error object left for application code to read once a send has already failed. This
* endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after.
* `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already
* fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of
* returning a normal response, so it can't be used for this specific check either.
*/
export interface ConversationContext {
topic: string;
peer_user_id: string;
display_name: string;
can_send: boolean;
relationship: 'blocked_by_me' | 'blocked_by_peer' | string;
capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean };
}
export function getConversationContext(topic: string) {
return imGet<ConversationContext>(`/conversations/${encodeURIComponent(topic)}/context`);
}
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
}
export function blockUser(userId: string) {
return imPut<void>(`/blocks/${encodeURIComponent(userId)}`);
}
export function unblockUser(userId: string) {
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
}
/** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this
* backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug
* that comes from assuming a 200 means every item succeeded). */
export function batchUnblockUsers(userIds: string[]) {
return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', {
user_ids: userIds,
});
}
export interface BlockedUser {
user: { user_id: string; display_name?: string; avatar_file_id?: string };
blocked_at: string;
}
/** Unlike `/friends`, this endpoint nests the user under `.user` (docs/IM-前后端对接文档.md:1379,1428,
* both the prose and the JSON example agree, and the old app's own consumer reads `item.user.
* display_name` -- not a flat item like Friend). */
export function getBlocks(cursor = '') {
return imGet<{ items: BlockedUser[]; next_cursor?: string }>(`/blocks${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
}
-510
View File
@@ -1,510 +0,0 @@
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
// instead of the doc where they conflict (see each function's own comment):
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
// - group-files search param is `q`, not `query`
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
import { fetchAllPages } from './client';
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
export interface GroupPermissions {
can_edit_profile?: boolean;
can_invite_members?: boolean;
can_manage_members?: boolean;
can_manage_settings?: boolean;
can_leave?: boolean;
can_mention_all?: boolean;
}
export interface GroupSummaryItem {
topic: string;
name: string;
avatar_file_id?: string;
member_count: number;
my_role: GroupRole;
updated_at?: string;
}
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
const qs = new URLSearchParams();
if (category) qs.set('category', category);
if (query) qs.set('query', query);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
}
export interface GroupProfile {
topic: string;
name: string;
avatar_file_id?: string;
description?: string;
owner?: string;
member_count: number;
version: number;
permissions?: GroupPermissions;
}
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
return imPost<GroupProfile>('/groups', body);
}
export function getGroup(topic: string) {
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
}
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
}
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
* example for this response, so this was a guess until checked against the real payload. */
export interface GroupGovernance {
join_approval: boolean;
join_approval_editable?: boolean;
mute_all: boolean;
mute_all_editable?: boolean;
member_upload_files: boolean;
member_upload_files_editable?: boolean;
member_private_chat: boolean;
member_private_chat_editable?: boolean;
member_invite: boolean;
member_invite_editable?: boolean;
version: number;
}
/**
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
* file hadn't typed at all until this pass.
*/
export interface GroupSettings {
topic: string;
name: string;
description?: string;
avatar_file_id?: string;
owner: string;
my_role: GroupRole;
permissions?: GroupPermissions;
member_count: number;
pinned: boolean;
mute_notifications: boolean;
message_retention?: string;
governance?: GroupGovernance;
version: number;
}
export function getGroupSettings(topic: string) {
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
}
export interface GroupMemberSettings {
pinned: boolean;
mute_notifications: boolean;
version: number;
updated_at: string;
}
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
* locking at all (the server bumps its own stored version unconditionally on every call); the
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
}
export function leaveGroup(topic: string) {
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
}
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
* `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
* fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
* a truly empty body under that header as malformed rather than "no optional fields provided". */
export function disbandGroup(topic: string, actionToken: string) {
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
body: {},
headers: { 'X-Action-Token': actionToken },
});
}
export type GroupRole = 'owner' | 'admin' | 'member';
export interface GroupMember {
user_id: string;
/**
* Real, live-confirmed gotcha (2026-09-24): this is NOT always the member's own real name.
* Reading server/im_http.go's userSummaryPayload confirms `display_name` here unconditionally
* prefers the CALLING viewer's own private remark for that member when one is set, falling back
* to their real public nickname only when the viewer has no remark for them -- correct and
* intended for rendering a list to yourself (README's member list/search), completely wrong for
* anything that gets baked into shared message CONTENT once and read by everyone from then on
* (an @mention's label): a real reported bug was exactly this -- a group owner's own private
* remark for a member ("小号1") ended up as that member's permanent @mention text, which every
* OTHER member then saw with no idea who it meant. Use `nickname` (below) instead for that case.
*/
display_name?: string;
/** Name chosen by this member for this group. */
group_nickname?: string;
/** The member's own real public profile nickname -- same backend response, but from
* copyUserNickname(), which reads straight off their public profile and is NEVER shadowed by the
* viewer's own remark the way `display_name` above is. This is what stays correct for everyone
* once baked into shared content (an @mention's label), not just for the viewer's own screen. */
nickname?: string;
avatar_file_id?: string;
role: GroupRole;
mentionable?: boolean;
eligible?: boolean;
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
ineligible_reason?: string;
acs?: { want?: string; given?: string; mode?: string };
}
export function getGroupMembers(
topic: string,
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
) {
const qs = new URLSearchParams();
if (opts.query) qs.set('query', opts.query);
if (opts.cursor) qs.set('cursor', opts.cursor);
if (opts.role) qs.set('role', opts.role);
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
if (opts.mentionable) qs.set('mentionable', 'true');
if (opts.muteEligible) qs.set('mute_eligible', 'true');
const suffix = qs.toString();
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
);
}
export function getAllGroupMembers(
topic: string,
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
) {
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
}
/**
* `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists
* (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a
* plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or
* themselves. An earlier pass through the protocol doc found no such operation at all -- that was
* true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time.
*/
export function removeGroupMember(topic: string, userId: string) {
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
}
/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner;
* removing a plain member needs admin-or-better, removing an admin needs the owner specifically.
* Shared by every surface that renders a per-member remove affordance (GroupMembersModal's full
* list, GroupDetailColumn's compact avatar grid) so the rule can't drift between the two. */
export function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean {
if (!myUserId || member.user_id === myUserId) return false;
if (myRole === 'member') return false;
if (member.role === 'owner') return false;
if (member.role === 'admin' && myRole !== 'owner') return false;
return true;
}
/** Dedicated self-profile route. It returns the real optimistic-lock `version` on every read, so
* callers do not need to remember the last save's version between edits. */
export function getMyGroupProfile(topic: string) {
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
}
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
nickname,
version,
});
}
export function getGroupRoleLimits(topic: string) {
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
}
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
return imPost<{
succeeded: Array<{ user_id: string; role: GroupRole }>;
failed?: Array<{ user_id: string; reason: string }>;
admins: GroupMember[];
version: number;
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
}
export function removeGroupAdmin(topic: string, userId: string, version: number) {
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
{ headers: { 'If-Match': String(version) } },
);
}
/** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) --
* no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint
* never returns a readable name, only the bare id -- callers needing one must cross-reference the
* member list themselves, same as GroupFilesModal already has to for `uploader_id`). */
export interface GroupMute {
user_id: string;
muted_by?: string;
muted_at: string;
expires_at?: string | null;
reason?: string;
status?: string;
version?: number;
}
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
`/groups/${encodeURIComponent(topic)}/mutes`,
{ members, reason },
);
}
function getGroupMutes(topic: string, cursor = '') {
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
}
/** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own
* `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page
* function directly, silently truncating both the list and its own header count once a group has
* enough muted members to span more than one page. */
export function getAllGroupMutes(topic: string) {
return fetchAllPages((cursor) => getGroupMutes(topic, cursor));
}
export function unmuteGroupMember(topic: string, userId: string) {
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
}
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
* `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
return imPost<{ owner: string; previous_owner: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/owner-transfer`,
{ target_user_id: targetUserId, version },
{ headers: { 'X-Action-Token': actionToken } },
);
}
export function getGroupGovernance(topic: string) {
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
}
export function updateGroupGovernance(
topic: string,
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
) {
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
}
/**
* Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the
* doc's own single-outcome worked example): `"joined"` when the group's own governance AND the
* target's own privacy setting both don't require approval (added immediately, no further action
* needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a
* real GroupInvitation record is created instead, delivered to the target as a system notification --
* see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this
* app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a
* `reason`.
*/
export interface GroupInvitationResult {
user_id: string;
status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed';
invitation_id?: string;
reason?: string;
}
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
user_ids: userIds,
message,
});
}
/**
* The target's own action on a `group.invitation.created` system notification (README/docs
* :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource
* directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group
* immediately on success (no further approval step at this point -- any approval requirement was
* already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status
* instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself,
* which this same endpoint resolves).
*/
export function acceptGroupInvitation(invitationId: string) {
return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`);
}
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
export interface GroupJoinRequest {
request_id: string;
user_id: string;
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
* a self-initiated request (search/QR). */
inviter_id: string;
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
message?: string;
status: string;
source: string;
created_at: string;
handled_at?: string;
handled_by?: string;
version: number;
}
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
* "request to join" with no QR code involved would omit both, though this app doesn't have another
* UI entry point that reaches a group's join-requests without already being a member (no public
* group directory/search exists anywhere in this codebase or its design handoff). */
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests`,
body,
);
}
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
const qs = new URLSearchParams({ status });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
}
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
return imPost<{ request_id: string; status: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
{ version },
);
}
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
return imPost<{ request_id: string; status: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
{ version },
);
}
/** Confirmed live: `announcement` is always a present object, even with nothing published --
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
export function getGroupSummary(topic: string) {
return imGet<{
pending_join_request_count: number;
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
permissions?: GroupPermissions;
}>(`/groups/${encodeURIComponent(topic)}/summary`);
}
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
* the `published_by` object this file originally guessed -- resolve it against the group's member
* list for a display name/role, same as any other bare user id in this API. When nothing has been
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
* `content` for emptiness, not object existence.
* `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this
* type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed
* by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three).
* Optional here because an old cached response (or a tenant not yet on the new deploy) may still
* omit them. */
export interface GroupAnnouncement {
announcement_id: string;
topic: string;
content: string;
version: number;
published_at: string;
author: string;
permissions?: { can_edit?: boolean };
read_count?: number;
member_count?: number;
read_summary?: { read_count: number; member_count: number };
}
export function getLatestGroupAnnouncement(topic: string) {
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
}
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
}
export function readGroupAnnouncement(topic: string, announcementId: string) {
return imPost<{ announcement_id: string; read_at: string }>(
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
{},
);
}
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
* previously declared -- undetected until now because this list only ever rendered its own
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
export interface GroupFile {
group_file_id: string;
file_id: string;
file_name: string;
file_size: number;
mime_type: string;
uploader_id?: string;
created_at: string;
download_state?: string;
}
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (type) qs.set('type', type);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
}
/**
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
* set below.
*/
export function addGroupFile(
topic: string,
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
) {
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
}
export function deleteGroupFile(topic: string, groupFileId: string) {
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
}
-17
View File
@@ -1,17 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useSessionStore } from '@/stores/session';
import { getBootstrap } from './bootstrap';
/**
* Bootstrap config barely changes within a session -- fetch once, keep indefinitely. Keyed by
* tenant code, not just `['bootstrap']`, so switching tenants (TenantEntryPage's "切换企业", no full
* page reload) fetches the NEW tenant's own message_policy/features/permissions/security instead of
* silently keeping the previous tenant's frozen (staleTime: Infinity) response around -- the
* queryClient itself is a single app-wide instance that's never cleared on tenant switch.
*/
export function useBootstrap() {
const tenantCode = useSessionStore((s) => s.tenant?.code);
return useQuery({ queryKey: ['bootstrap', tenantCode], queryFn: getBootstrap, staleTime: Infinity });
}
-193
View File
@@ -1,193 +0,0 @@
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
// calls this then hard-deletes the message from the local topic cache itself.
import type { DraftyContent } from 'tinode-sdk';
import { imGet, imPost, imPut } from './client';
export interface RecallResult {
topic: string;
seq: number;
status: string;
version: number;
updated_at: string;
}
/**
* This backend tracks its own optimistic-concurrency `version` per message (distinct from Tinode's
* `seq` -- confirmed in docs/IM-前后端对接文档.md:1248, `{"version":1,"reason":"..."}` request,
* `{"version":2,...}` response), which is NOT delivered anywhere in the native WS `{data}` packet
* this app reads messages from (confirmed live: a real message's WS payload carries no `version`
* field at all). Since there's currently no source for a message's true current version, this
* defaults to 1 (the version of any never-before-edited-or-recalled message, the overwhelmingly
* common case) -- a real edit/recall race would surface as a version-conflict error from the
* server, which the caller should treat as "refetch and retry," not silently ignore.
*/
export function recallMessage(topic: string, seq: number, version = 1, reason?: string) {
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
}
interface DraftyEntityData {
ref?: string;
val?: string;
mime?: string;
name?: string;
size?: number;
width?: number;
height?: number;
duration?: number;
}
interface DraftyEntity {
tp?: string;
data?: DraftyEntityData;
}
/**
* docs/IM-前后端对接文档.md:1879,1889,1898 describe `file`/`link` results as flat `{file:{...}}` /
* `{url,title,...}` objects -- confirmed LIVE against the real server to be wrong. The real payload
* wraps a `snapshot` of the ORIGINAL raw message (`snapshot.content` is a full `{txt,fmt,ent}`
* Drafty document -- pass it to lib/messages/drafty.ts's renderDrafty as-is, don't reconstruct it)
* around thin search metadata. `snapshot.text` (not modeled here) is a redundant, sometimes-JSON-
* stringified fallback the server fills in for non-text messages -- do not render it.
*/
export interface SearchHit {
seq: number;
from: string;
ts: string;
availability?: string;
matches?: Array<{ start: number; end: number }>;
snippet?: string;
snapshot?: {
// A plain, unformatted text message's `content` is confirmed LIVE to arrive as a bare string
// (not the `{txt,fmt,ent}` object shape this field was originally typed as) -- the same
// string-vs-object duality `renderDrafty` (lib/messages/drafty.tsx) already handles for every
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
content?: DraftyContent | string;
// Confirmed LIVE (2026-09-24, a real mobile-sent flat-text hit): the snapshot also carries the
// original message's `head`, same as favorites.ts's own snapshot already documents. Needed to
// recognize a mobile-sent flat content shape (`{text:...}`/`{file_id,...}`/etc., tagged only by
// `head['x-im-type']`) BEFORE handing `content` to renderDrafty -- that flat shape isn't a real
// Drafty document, and used to crash the whole search modal (no `.txt`/`.text` field `renderDrafty`
// recognizes -> `Drafty.toPlainText` returns `undefined` -> highlightMatches() called `.toLowerCase()`
// on it). See HistorySearchModal's SnapshotContent for the fix.
head?: Record<string, unknown>;
};
}
/**
* README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1879,1889,1898.
* `file`/`link` are this same search endpoint (not the `/media` one below) with an empty `q` -- per
* §7.13/7.14 of the protocol doc, only `image`/`video` actually live behind `/media`.
*/
export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' | 'link' = 'text', cursor = '') {
const qs = new URLSearchParams({ q: query, type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ topic: string; items: SearchHit[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`);
}
/** README §3.4's 4-icon grid (图片/视频/文件/链接) -- a UI-level union for "which of the 4 tabs is
* selected." Only 2 of the 4 are actually served by `/media` (see MediaFetchType below); file/link
* go through searchTopicMessages instead (docs/IM-前后端对接文档.md §7.13-7.16). */
export type MediaType = 'image' | 'video' | 'file' | 'link';
/** The subset of MediaType that `/media` itself understands. */
export type MediaFetchType = 'image' | 'video';
/**
* docs/IM-前后端对接文档.md:1907 describes a flat `{seq,thumbnail_file_id,file_id,width,height,ts,
* availability}` -- confirmed LIVE to be wrong the same way as SearchHit above: the real payload is
* the raw message's own `{ent,fmt,txt}` at the top level (no `snapshot` wrapper this time), so the
* actual attachment lives at `ent[0].data`, same DraftyEntityData shape as everywhere else.
*/
export interface MediaItem {
seq: number;
from?: string;
ts: string;
availability: string;
ent?: DraftyEntity[];
}
export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') {
const qs = new URLSearchParams({ type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
}
export interface TopicDraft {
topic: string;
content: string;
entities: unknown[];
version: number;
updated_at?: string;
}
/**
* README §3.2's unsent-message-draft persistence (Zentao #19: input clears on conversation switch/
* page refresh). docs/IM-前后端对接文档.md:1003/1247 marks `PUT /v1/im/topics/{topic}/draft` as
* "待实现" (not yet implemented) and describes local-only storage as the baseline -- but confirmed by
* reading im_be's own source (server/im_http.go's serveIMTopicDraft, wired to store.Conversations.
* GetDraft/PutDraft), this endpoint is REAL and already live, the doc is just stale. A draft that's
* never been saved comes back as a 200 (not 404) with `content:""`/`version:0`, so GET never needs
* special-casing a not-found error -- confirmed in the same source.
*/
export function getTopicDraft(topic: string) {
return imGet<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`);
}
/** `entities` intentionally always `[]` for now -- this app's draft state is plain text only (no
* in-progress @mention/attachment entities tracked yet), and the field is optional server-side. */
export function putTopicDraft(topic: string, content: string, version: number) {
return imPut<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version });
}
/**
* A single message's "收藏" context-menu action is really just a one-item batch (docs/
* IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->
* `{"created_count","existing_count","failed":[]}`. `seq` travels as a string per the documented
* example, not a number.
*/
export function favoriteMessages(items: Array<{ topic: string; seq: number }>) {
return imPost<{ created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }>('/favorites/batch', {
messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })),
});
}
export interface ForwardResultItem {
source?: { topic: string; seq: number };
target_topic: string;
client_mid: string;
seq: number;
status: 'sent' | 'pending' | 'uncertain';
reason?: string;
}
export interface ForwardFailureItem {
source?: { topic: string; seq: number };
target_topic?: string;
status: 'failed';
reason: string;
}
/**
* `POST /v1/im/messages/forward` (server/im_http.go's serveIMMessageForward, confirmed by reading
* the real Go source directly, not the protocol doc) -- the SAME backend-authoritative forward path
* both real mobile clients use (confirmed by reading their real source too: iOS's
* ChatDetailViewController+Forward.swift, Android's ImMessageActionApi.forward(), NEITHER ever builds
* a `{pub}` packet client-side). One call handles every (source, target) pair at once -- up to 100
* sources x 20 targets -- with per-pair success/failure reporting, and the server copies
* `msg.Content` verbatim regardless of shape (real Drafty or a mobile-sent flat object), so unlike
* the client-side approach this replaces, there is no content-shape detection to get wrong here.
* `comment` is deliberately NOT wired here even though the endpoint accepts one -- callers keep
* sending an optional forward comment as its own separate, ordinary text message afterward (matching
* this app's existing, unchanged UX: the comment appears AFTER the forwarded message(s), whereas the
* endpoint's own built-in `comment` would save it BEFORE -- a real behavioral difference that's the
* caller's choice to take on, not something to silently opt into here).
*/
export function forwardMessages(sources: Array<{ topic: string; seq: number }>, targetTopics: string[]) {
return imPost<{ items: ForwardResultItem[]; failed: ForwardFailureItem[] }>('/messages/forward', {
sources,
target_topics: targetTopics,
});
}
-53
View File
@@ -1,53 +0,0 @@
// System notifications REST endpoints (docs/IM-前后端对接文档.md:890-912). Confirmed live field
// facts (via a research pass over docs/IM-后端问题清单*.md, 问题30/新增32/33) that diverge from
// this doc's own worked example -- see each item's comment below.
import { imGet, imPost } from './client';
export interface SystemNotification {
id: string;
/** Real values seen include the `friend.*`/`group.*`/`message.*`/`meeting.*`/`security.risk.*`
* family from the WS business-event table (doc lines 408-420) -- route on this, not `action.type`
* (see below). */
event_type: string;
title: string;
/** Confirmed live (问题30): this is sometimes a raw machine code (`"accepted"`,
* `"security.risk.cancel"`) instead of human text, and can be empty. Never render blindly --
* always run through NOTIFICATION_TEXT first. */
summary: string;
/**
* Confirmed live (问题30) the shape varies by source and does NOT reliably match this doc's own
* worked example (`{"type":"friend_request","request_id":"frq_1"}`) -- a real friend-request
* notification's `action` was just `{"user_id":"usr..."}`, no `type` key at all. Group events:
* `{topic}`. Meeting events: inconsistently either `{module,resource_id}` or `{meeting_id}`.
* New-device-login's documented `{device_name,platform,ip_masked,login_at,security_action}`
* shape (doc line 891) is confirmed NOT IMPLEMENTED server-side at all (新增12, still open) --
* never assume these fields are populated.
*/
action?: {
type?: string;
request_id?: string;
topic?: string;
user_id?: string;
device?: string;
ip?: string;
[key: string]: unknown;
};
read_at: string | null;
created_at: string;
}
export function getSystemNotifications(cursor = '', limit = 20) {
const qs = new URLSearchParams({ limit: String(limit) });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: SystemNotification[]; next_cursor?: string }>(`/system-notifications?${qs.toString()}`);
}
/**
* Confirmed live (新增32, still open): marking an ALREADY-read notification echoes back whatever
* `read_at` you send as if it succeeded, but the stored value stays frozen at the first real write
* -- the response lies about updating it. Never treat this response's `read_at` as authoritative;
* only use it to flip local `read_at` truthy/falsy, matching the old app's own workaround.
*/
export function markSystemNotificationRead(id: string, readAt = new Date().toISOString()) {
return imPost<{ status: string; id: string; read_at: string }>(`/system-notifications/${encodeURIComponent(id)}/read`, { read_at: readAt });
}
-80
View File
@@ -1,80 +0,0 @@
// Own-profile/privacy REST endpoints (docs/IM-前后端对接文档.md ch.10, lines 2281-2397). Confirmed
// against the doc's own worked examples (table lines 2490-2494) and, where the doc has no example
// at all, against the rewritten old app's real working code (im_webapp/src/views/personal-settings-
// view.jsx) -- flagged per-field below.
import { imGet, imPatch } from './client';
/** `GET /v1/im/me` -- thin summary for a home/header card. Distinct from `getMyProfile` below (the
* full profile-edit source) -- confirmed live these are two separate endpoints with different
* field sets, not one response read two ways. */
export interface MeSummary {
user_id: string;
profile: { public_account: string; nickname: string; avatar_file_id?: string; signature?: string; version: number };
profile_version: number;
}
export function getMe() {
return imGet<MeSummary>('/me');
}
export interface MyProfile {
avatar_file_id?: string;
nickname: string;
birth_date?: string;
signature?: string;
gender?: 'male' | 'female' | 'other' | string;
region?: string;
email?: string;
public_account: string;
version: number;
field_rules?: { nickname?: { min_length: number; max_length: number }; signature?: { max_length: number } };
}
export function getMyProfile() {
return imGet<MyProfile>('/me/profile');
}
/** doc.md:2492 only shows nickname/signature in its worked example; gender/region have no doc
* example at all but the old app's real, working code PATCHes them the identical way (`gender`
* enum confirmed `male|female|other`, personal-settings-view.jsx:214). */
export function updateMyProfile(patch: Partial<Pick<MyProfile, 'nickname' | 'birth_date' | 'signature' | 'gender' | 'region' | 'avatar_file_id' | 'email'>> & { version: number }) {
return imPatch<MyProfile>('/me/profile', patch);
}
export interface AddMeMethods {
account: boolean;
phone: boolean;
qr: boolean;
group: boolean;
}
/**
* `profile_visibility`/`last_seen_visibility` enum confirmed as `everyone|friends|nobody` only via
* the old app's own code comment (the doc's worked examples only ever show `"friends"`) -- the
* server's validator 400s `contacts`/`hidden` outright, don't offer those as options.
*
* There is NO "加我为好友时需要验证" field anywhere in this response -- confirmed absent both in
* the doc and in the old app's own `UserPrivacySettings` research (personal-settings-view.jsx:534-
* 538: the design reference shows a control for it, but neither the response shape nor the group-
* invite backend logic has anything to gate). Don't build that toggle.
*/
export interface PrivacySettings {
add_me_methods: AddMeMethods;
profile_visibility: 'everyone' | 'friends' | 'nobody' | string;
last_seen_visibility: 'everyone' | 'friends' | 'nobody' | string;
read_receipts: boolean;
version: number;
editable?: Partial<Record<keyof PrivacySettings, boolean>>;
}
export function getMyPrivacy() {
return imGet<PrivacySettings>('/me/privacy');
}
export function updateMyPrivacy(
patch: Partial<{ add_me_methods: Partial<AddMeMethods>; profile_visibility: string; last_seen_visibility: string; read_receipts: boolean }> & {
version: number;
},
) {
return imPatch<PrivacySettings>('/me/privacy', patch);
}
-94
View File
@@ -1,94 +0,0 @@
// QR code REST endpoints (docs/IM-前后端对接文档.md §5.15.1). Confirmed LIVE against the real
// deployment (2026-09-08, two fresh test accounts) -- `resolveQRCode`'s real response shape nests
// `relationship`/`can_add`/`action`/`public_account` all under `user`, unlike the doc's own worked
// example which shows them as siblings of `user` at the top level. Also confirmed live: resolving a
// code against the SAME account that minted it is rejected (`400 MALFORMED_REQUEST`, `field:"token"`,
// message "malformed request" -- distinct from a genuinely garbage token, which instead gets message
// "qr code invalid" with no `details.field` at all) -- cross-account resolution works correctly.
//
// The security rationale for why this exists at all instead of just encoding a raw user id (doc line
// 1299): "二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录
// Token" -- the QR image payload is `qr_payload` (an `im://qr?token=...&purpose=...` URI), not
// `qr_token` alone, matching the old app's own `im://qr` scheme (im_webapp/src/views/tinode-web.jsx
// confirmed this exact prefix check before ever sending a scanned string to `resolve`).
import { imPost } from './client';
interface QRCodeDisplayProfile {
user_id: string;
display_name: string;
public_account?: string;
}
export interface MyQRCode {
qr_token: string;
/** What actually gets encoded into the QR image -- `im://qr?token=...&purpose=...`. */
qr_payload: string;
expires_at: string;
display_profile: QRCodeDisplayProfile;
}
export function createMyQRCode(expiresInSeconds = 86400) {
return imPost<MyQRCode>('/me/qrcodes', { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
}
/** `identifier`: user_id/username/public_account -- viewing a specific friend's own QR card. */
export function createUserQRCode(identifier: string, expiresInSeconds = 86400) {
return imPost<MyQRCode>(`/users/${encodeURIComponent(identifier)}/qrcodes`, { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
}
export interface QRResolveUser {
user_id: string;
display_name: string;
public_account?: string;
// docs/IM-前后端对接文档.md:1289 -- same real direction-specific enum as friends.ts's
// UserSearchResult/UserProfile, never a bare "pending".
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
can_add: boolean;
action: string;
}
/** Confirmed LIVE (2026-09-08, same account that minted the code -- a real cross-account "scan a
* stranger's group code" resolve hasn't been exercised, this session only had one account free):
* a `purpose:"join_group"` resolve nests the group under `group` (NOT `group_snapshot`, despite
* the mint response using that key -- resolve and mint use different field names for the same
* shape), and carries a `status` this specific case's doc example (line 1737, mint-only) never
* showed: resolving your own group's code (i.e. already a member) comes back `status:"joined"`.
* What a genuinely-not-yet-a-member resolve returns for `status` is unconfirmed -- treated as
* "anything other than 'joined'" in QRScannerModal/AddFriendModal, i.e. the general case that still
* needs a real join-request submission. */
export interface QRResolveGroup {
topic: string;
name: string;
member_count: number;
}
export interface QRResolveResult {
purpose: 'add_friend' | 'join_group' | string;
source: string;
source_topic: string;
expires_at: string;
user?: QRResolveUser;
group?: QRResolveGroup;
group_snapshot?: QRResolveGroup;
/** join_group only -- `"joined"` means the scanning account is already a member (confirmed live);
* any other value (including absent) is treated as "not a member yet, submit a join request". */
status?: string;
}
export function resolveQRCode(token: string, purpose: 'add_friend' | 'join_group' | string = 'add_friend') {
return imPost<QRResolveResult>('/qrcodes/resolve', { token, purpose });
}
/** docs/IM-前后端对接文档.md:1590 -- 群管理员/群主展示的入群二维码 (mirrors createMyQRCode/
* createUserQRCode's shape, just scoped to a group and carrying `group_snapshot` instead of a
* personal `display_profile`). */
export interface GroupQRCode {
qr_token: string;
qr_payload: string;
expires_at: string;
group_snapshot: QRResolveGroup;
}
export function createGroupQRCode(topic: string, expiresInSeconds = 86400) {
return imPost<GroupQRCode>(`/groups/${encodeURIComponent(topic)}/qrcodes`, { expires_in_seconds: expiresInSeconds });
}
-41
View File
@@ -1,41 +0,0 @@
// docs/IM-前后端对接文档.md:1822,1898,1943 -- links surfaced anywhere in the UI (favorites, history
// search) must go through this check before being opened; the server, not the client, holds the
// risk classification (it fetches/caches the URL server-side to avoid the client ever hitting an
// attacker-controlled address directly, which is also why there's no client-side allow/block list).
import { imPost } from './client';
export interface UrlSafetyResult {
url: string;
decision: 'allow' | 'warn' | 'block';
risk_level: string;
reason?: string;
risk_challenge_id?: string;
}
export function checkUrlSafety(url: string, source: string) {
return imPost<UrlSafetyResult>('/security/urls/check', { url, source });
}
/** Sensitive-action re-verification (docs/IM-前后端对接文档.md:762-766, §6.17's group-dissolve line).
* Confirmed by reading im_be's own `validIMSecurityChallengeScene` (server/im_http.go) -- the doc
* text only spells out the two youth-mode scenes by name, but the server also accepts
* `group_dissolve`/`group_owner_transfer`, which is what actually lets this flow work for either. */
export type SecurityChallengeScene = 'group_dissolve' | 'group_owner_transfer';
export interface SecurityChallenge {
challenge_id: string;
phone_mask: string;
expires_in_seconds: number;
resend_after_seconds: number;
}
export function requestSecurityChallenge(scene: SecurityChallengeScene) {
return imPost<SecurityChallenge>('/security/challenges', { scene, channel: 'sms' });
}
export function verifySecurityChallenge(challengeId: string, code: string) {
return imPost<{ action_token: string; action: string; expires_at: string }>(
`/security/challenges/${encodeURIComponent(challengeId)}/verify`,
{ code },
);
}

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