Convert im_webapp from static mock prototypes to real backend integration

Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 11:40:53 +08:00
co-authored by Claude Sonnet 5
parent ba8f48ee83
commit 251c9de87c
36 changed files with 52812 additions and 5199 deletions
+1124 -59
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -42
View File
@@ -10,7 +10,9 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"firebase": "^12.15.0", "firebase": "^12.15.0",
"jsqr": "^1.4.0",
"libphonenumber-js": "^1.10.40", "libphonenumber-js": "^1.10.40",
"pinyin-pro": "^3.29.3",
"qrcodejs": "^1.0.0", "qrcodejs": "^1.0.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
@@ -2731,9 +2733,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2748,9 +2747,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2765,9 +2761,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2782,9 +2775,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4213,9 +4203,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4230,9 +4217,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4247,9 +4231,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4264,9 +4245,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4281,9 +4259,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4298,9 +4273,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4315,9 +4287,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4332,9 +4301,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4349,9 +4315,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4366,9 +4329,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -8054,6 +8014,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/kind-of": { "node_modules/kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -8708,6 +8674,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/pinyin-pro": {
"version": "3.29.3",
"resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.29.3.tgz",
"integrity": "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==",
"license": "MIT"
},
"node_modules/pirates": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+2
View File
@@ -79,7 +79,9 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"firebase": "^12.15.0", "firebase": "^12.15.0",
"jsqr": "^1.4.0",
"libphonenumber-js": "^1.10.40", "libphonenumber-js": "^1.10.40",
"pinyin-pro": "^3.29.3",
"qrcodejs": "^1.0.0", "qrcodejs": "^1.0.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
+690
View File
@@ -0,0 +1,690 @@
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS protocol.
// Endpoint shapes verified directly against the deployed backend (im_team/im_server, server/im_http.go).
import { API_KEY } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import LocalStorageUtil from './local-storage.js';
const DEFAULT_API_BASE = '/v1/im';
const DEFAULT_FILE_BASE = '/v0/file';
export class ImApiError extends Error {
constructor(message, reason, status, details) {
super(message);
this.reason = reason;
this.status = status;
this.details = details;
}
}
// docs/IM-前后端对接文档.md §12.4's full reason list. Every existing call site already does
// `catch(err => onToast(err.message || '...'))`, so translating known reasons at the one place the
// error is constructed fixes all of them at once, rather than needing a matching change at every
// individual catch block scattered across the app.
const REASON_MESSAGES = {
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
ACCOUNT_DISABLED: '账号已被禁用',
RATE_LIMITED: '操作过于频繁,请稍后再试',
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
FORBIDDEN: '没有权限执行此操作',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截'
};
function requestId() {
return (typeof crypto != 'undefined' && crypto.randomUUID) ? crypto.randomUUID() :
'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);
}
// Tenant context persists in localStorage (not sessionStorage): it must survive browser restarts
// the same way the auth-token does, so a returning user's auto-login can still supply `hi.tenant`.
export function getTenantContext() {
return LocalStorageUtil.getObject('im-tenant');
}
export function currentTenantCode() {
const tenant = getTenantContext();
return (tenant && tenant.code) || 'default';
}
function apiBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.api) || DEFAULT_API_BASE).replace(/\/$/, '');
}
function fileBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.file) || DEFAULT_FILE_BASE).replace(/\/$/, '');
}
// tinode-web.jsx always sets this on login/reconnect (sessionStorage, tab-lifetime), independent of
// whether the user opted into "keep logged in" (which only controls the separate localStorage token
// used for surviving a browser restart). REST calls need a token for the current session regardless.
function authToken() {
return sessionStorage.getItem('im-session-token') || '';
}
function normalizeResponse(payload) {
return (payload && payload.code === 0 && payload.data !== undefined) ? payload.data : payload;
}
// Core request helper. `auth: false` skips the Authorization header (pre-login calls).
export async function imRequest(path, {method = 'GET', body, headers, auth = true, tenantCodeOverride} = {}) {
const finalHeaders = {
'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 = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (e) {
throw new ImApiError(response.ok ? '接口返回非 JSON 数据' : (response.statusText || '接口请求失败'),
'NON_JSON_RESPONSE', response.status, text.slice(0, 160));
}
}
if (!response.ok || (payload && payload.code && payload.code !== 0)) {
// A known reason always wins over the raw backend message -- gives every caller (they all just do
// `err.message || '...'`) the doc-mandated, consistently-worded text instead of whatever string
// that particular backend handler happened to write, and instead of duplicating this mapping at
// every individual call site. An unrecognized reason still falls back to the backend's own message
// (not silently replaced with a generic string) since this backend's messages have consistently
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
// reasons this app doesn't otherwise know how to react to fall through to it.
const known = payload.reason && REASON_MESSAGES[payload.reason];
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason, response.status, payload.details);
}
return normalizeResponse(payload);
}
const idem = () => ({'Idempotency-Key': requestId()});
export const imGet = (path, opts) => imRequest(path, opts);
export const imPost = (path, body, opts = {}) => imRequest(path, {method: 'POST', body, headers: idem(), ...opts});
export const imPatch = (path, body, opts = {}) => imRequest(path, {method: 'PATCH', body, headers: idem(), ...opts});
export const imPut = (path, body, opts = {}) => imRequest(path, {method: 'PUT', body, headers: idem(), ...opts});
export const imDelete = (path, opts = {}) => imRequest(path, {method: 'DELETE', headers: idem(), ...opts});
function qs(params) {
const usp = new URLSearchParams();
Object.entries(params || {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
usp.set(k, v);
}
});
const s = usp.toString();
return s ? '?' + s : '';
}
// -- Public uploads (multipart; used before login and for standalone attachments not tied to a chat message). --
export async function uploadPublicFile(file, purpose, onProgress) {
const form = new FormData();
form.append('purpose', purpose);
form.append('file', file);
// Use XHR instead of fetch to get upload progress events.
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', apiBase() + '/public/uploads');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('X-IM-Schema', '1');
xhr.setRequestHeader('X-Tenant-Code', currentTenantCode());
xhr.setRequestHeader('X-Request-ID', requestId());
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
if (xhr.status >= 200 && xhr.status < 300 && payload.code === 0) {
resolve(normalizeResponse(payload));
} else {
reject(new ImApiError(payload.message || payload.reason || '上传失败', payload.reason, xhr.status, payload.details));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Native large-file upload (Ch.6 group avatars & group files). uploadPublicFile() above can't be reused --
// its `purpose` is server-whitelisted to exactly 'enterprise_license'/'tenant_entry_feedback'. The vendored
// SDK's own tinode.getLargeFileHelper().upload() talks to the same underlying native endpoint but gets two
// headers wrong for this backend (verified live against the real server, not just read from source):
// it sends 'X-Tinode-APIKey' (server only accepts 'X-MeetingIM-APIKey', same rename Phase 0 already applied
// everywhere else in this file) and 'X-Tinode-Auth' (server's getHttpAuth() doesn't recognize that header
// name at all, only 'X-MeetingIM-Auth' or plain 'Authorization'). The auth *value* must be the native Tinode
// session token (tinode.getAuthToken().token, scheme 'Token') -- the REST layer's own sessionStorage Bearer
// token is a separate namespace and fails this endpoint's base64 secret decode with a 400. Response is
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
// extension suffix) since getFileUrl() already round-trips that exact format unmodified.
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
const url = ctrl.params.url;
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for sending chat attachments
// (messages-view.jsx). The vendored SDK's LargeFileHelper hardcodes the same two wrong headers
// uploadLargeFile() above works around ('X-Tinode-APIKey' instead of 'X-MeetingIM-APIKey', and
// 'X-Tinode-Auth' instead of 'Authorization') -- confirmed live: every image/video/file/voice message
// too large to send inband was silently failing to upload. Can't just call uploadLargeFile() at each
// call site instead, because messages-view.jsx also depends on LargeFileHelper's cancel-in-flight-send
// support (handleCancelUpload) and its urlPromise contract (Drafty.attachFile/insertImage/insertVideo/
// insertAudio expect the resolved value to be a bare URL string, or for video a [url, prevUrl] pair --
// not the {fileId,url,expires} object uploadLargeFile() returns). This mirrors just that subset of the
// real class's interface (upload() returning a bare-URL promise, onProgress, and cancel() aborting any
// still-in-flight XHRs via the same `this.xhr` array pattern the real LargeFileHelper.cancel() uses).
export function createChatUploader(tinode) {
const xhrs = [];
return {
onProgress: null,
upload(file) {
const self = this;
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.upload.onprogress = e => {
if (e.lengthComputable && self.onProgress) {
self.onProgress(e.loaded / e.total);
}
};
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url);
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for downloading a chat/favorite
// out-of-band attachment (content-library-view.jsx's DownloadPage). Same wrong-header bug as
// createChatUploader() above -- the vendored SDK's LargeFileHelper.download() also hardcodes
// 'X-Tinode-APIKey'/'X-Tinode-Auth', so every real (non-blob:/data:) download was silently failing.
// Mirrors the real class's download()/cancel() interface exactly (including the onProgress/onError
// callback-style signature DownloadPage already calls it with) so the call site needs no other change.
export function createFileDownloader(tinode) {
const xhrs = [];
return {
download(relativeUrl, filename, mimetype, onProgress, onError) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
const err = new ImApiError('尚未登录,无法下载文件', 'AUTH_REQUIRED', 401);
if (onError) { onError(err); }
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('GET', relativeUrl, true);
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.responseType = 'blob';
xhr.onprogress = e => { if (onProgress) { onProgress(e.loaded); } };
xhr.onload = () => {
if (xhr.status === 200) {
const link = document.createElement('a');
link.href = window.URL.createObjectURL(new Blob([xhr.response], {type: mimetype}));
link.style.display = 'none';
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
resolve();
} else {
const reader = new FileReader();
reader.onload = function() {
let err;
try {
const pkt = JSON.parse(this.result);
err = new ImApiError((pkt.ctrl && pkt.ctrl.text) || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
}
if (onError) { onError(err); }
reject(err);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = () => {
const err = new ImApiError('网络错误,下载失败', 'NETWORK_ERROR', 0);
if (onError) { onError(err); }
reject(err);
};
xhr.send();
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// -- 1.1 Tenant resolution & entry (pre-login, no Authorization header). --
export function resolveTenant(tenantCode) {
return imRequest('/tenants/resolve', {
method: 'POST', auth: false, tenantCodeOverride: tenantCode,
body: {tenant_code: tenantCode, platform: 'web', app_version: PACKAGE_VERSION || '0.25.3'}
});
}
export function submitTenantApplication(body) {
return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(), body});
}
export function submitTenantEntryFeedback(body) {
return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(), body});
}
// -- 1.4/1.5/1.6 Phone verification codes (register / reset_password / security scenes). --
export function requestVerificationCode(scene, phone) {
return imRequest('/auth/verification-codes', {method: 'POST', auth: false, headers: idem(), body: {scene, phone}});
}
export function verifyRegisterCode(challengeId, code) {
return imRequest('/auth/verification-codes/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function verifyPasswordResetCode(challengeId, code) {
return imRequest('/auth/password-reset/challenges/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function confirmPasswordReset(resetToken, newPassword) {
return imRequest('/auth/password-reset/confirm',
{method: 'POST', auth: false, headers: idem(), body: {reset_token: resetToken, new_password: newPassword}});
}
// -- Bootstrap / home. --
export function getBootstrap() {
return imRequest('/bootstrap');
}
export function getMe() {
return imRequest('/me');
}
// -- Realname (1.8/1.9). --
export function getRealnameStatus() {
return imRequest('/security/realname');
}
export function submitRealname(body) {
return imPost('/security/realname/submissions', body);
}
// -- Security challenge (1.7) & independent password (1.11). --
export function createSecurityChallenge(scene) {
return imPost('/security/challenges', {scene, channel: 'sms'});
}
export function verifySecurityChallenge(challengeId, code) {
return imPost('/security/challenges/' + encodeURIComponent(challengeId) + '/verify', {code});
}
export function setIndependentPassword(password, purpose, actionToken) {
return imPost('/settings/independent-password', {password, purpose, action_token: actionToken});
}
// `action` defaults server-side to 'close_youth_mode' if omitted (server/im_http.go
// imIndependentPasswordVerifyRequest) -- pass 'set_independent_password' to mint a token usable
// with enableYouthMode instead, letting an already-set password unlock re-enabling without
// forcing another SMS round-trip through the identity-check flow.
export function verifyIndependentPassword(password, purpose, action) {
return imPost('/settings/independent-password/verify', {password, purpose, action});
}
// -- Friends / contacts (Ch.5). --
export const getFriends = (query = '', cursor = '', initial = '') => imGet('/friends' + qs({query, cursor, initial}));
export const getFriendRequests = (direction = 'inbound', status = 'all', cursor = '') =>
imGet('/friend-requests' + qs({direction, status, cursor}));
export const createFriendRequest = (targetUserId, message = '', source = 'search', sourceTopic = '') =>
imPost('/friend-requests', {target_user_id: targetUserId, message, source, source_topic: sourceTopic});
export const acceptFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
export const rejectFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const deleteFriend = userId => imDelete('/friends/' + encodeURIComponent(userId));
export const updateFriendRemark = (userId, remark) => imPatch('/friends/' + encodeURIComponent(userId), {remark});
export const searchUsers = (query, type = 'account', cursor = '') => imGet('/users/search' + qs({q: query, type, cursor}));
export const getUserProfile = identifier => imGet('/users/' + encodeURIComponent(identifier) + '/profile');
export const createMyQRCode = (expiresInSeconds = 86400) => imPost('/me/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const createUserQRCode = (identifier, expiresInSeconds = 86400) =>
imPost('/users/' + encodeURIComponent(identifier) + '/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const resolveQRCode = (token, purpose = 'add_friend') => imPost('/qrcodes/resolve', {token, purpose});
export const getBlocks = (cursor = '') => imGet('/blocks' + qs({cursor}));
export const blockUser = userId => imPut('/blocks/' + encodeURIComponent(userId), {});
export const unblockUser = userId => imDelete('/blocks/' + encodeURIComponent(userId));
export const batchUnblockUsers = userIds => imPost('/blocks/batch-delete', {user_ids: userIds});
// -- Conversations (Ch.2/3). --
export const searchConversations = (query = '', cursor = '') => imGet('/search/conversations' + qs({q: query, cursor}));
export const getSystemNotifications = (cursor = '', limit = 20) => imGet('/system-notifications' + qs({cursor, limit}));
export const markSystemNotificationRead = id => imPost('/system-notifications/' + encodeURIComponent(id) + '/read', {read_at: new Date().toISOString()});
export const getConversationContext = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/context');
export const createP2PConversation = peerUserId => imPost('/conversations/p2p', {peer_user_id: peerUserId});
export const getConversationSettings = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/settings');
export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body);
// -- Messages (Ch.4/7). --
export const recallMessage = (topic, seq, version = 1, reason = 'mistake') =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason});
export const reviseMessage = (topic, seq, content, version = 1) =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/revisions', {content, client_mid: requestId(), version});
export const getMessageReceipts = (topic, seq, cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/receipts' + qs({cursor}));
// type='all' searches every message type (text/image/video/voice/file/link) -- the backend's own
// default when the param is omitted/empty is "no filter", so defaulting to 'text' here would have
// silently restricted every unqualified call to text-only messages.
export const searchTopicMessages = (topic, {query = '', type = 'all', cursor = ''} = {}) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/search' + qs({q: query, type, cursor}));
export const getMessageContext = (topic, seq, before = 20, after = 20) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/context' + qs({before, after}));
export const getTopicMedia = (topic, type = 'image', cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/media' + qs({type, cursor}));
export const getForwardTargets = (query = '', cursor = '') => imGet('/forward-targets' + qs({query, cursor}));
export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
export const getFavorites = ({type = 'all', query = '', cursor = ''} = {}) => imGet('/favorites' + qs({type, q: query, cursor}));
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
export const forwardFavorites = (favoriteIds, targetTopics, comment = '') =>
imPost('/favorites/forward', {favorite_ids: favoriteIds, target_topics: targetTopics, comment});
export const checkURL = (url, source = 'favorite') => imPost('/security/urls/check', {url, source});
export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
export async function fetchFileState(fileId, range = '') {
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// GET /v0/file/s/{id} is the native Tinode file-serving endpoint, not the /v1/im REST API --
// it's auth-gated by the vendored SDK's auth-handler registry, which is keyed by the literal
// scheme word in the header and only recognizes 'token' (used by uploadLargeFile above), never
// 'bearer'. Sending 'Bearer' here (as imRequest correctly does for actual /v1/im/* calls) gets a
// flat 400 "malformed" from every single call -- confirmed live against the real backend: same
// token value, only the scheme word differs. authToken() itself already holds the right value
// (tinode-web.jsx's handleLoginSuccessful stores tinode.getAuthToken().token verbatim into
// sessionStorage['im-session-token']), so no plumbing change is needed beyond the scheme word.
if (token) {
headers.Authorization = 'Token ' + token;
}
if (range) {
headers.Range = range;
}
const response = await fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers});
return {
ok: response.ok, status: response.status,
contentType: response.headers.get('content-type') || '',
contentLength: response.headers.get('content-length') || '',
acceptRanges: response.headers.get('accept-ranges') || '',
etag: response.headers.get('etag') || ''
};
}
export function publicFileBase() {
return fileBase();
}
// File serving (GET /v0/file/s/{id}) is auth-gated -- the upload endpoints never return a directly
// servable URL (publicUploadPayload only has file_id/purpose/mime_type/size/expires_at), so a plain
// <img src> can't be pointed at it (browsers don't attach custom headers to img/video/audio requests).
// This fetches the bytes with the right headers once per file_id and hands back a blob: object URL,
// cached so avatars/attachments repeated across a list (friends, search results, ...) aren't refetched.
const fileUrlCache = new Map();
export function getFileUrl(fileId) {
if (!fileId) {
return Promise.resolve(null);
}
if (fileUrlCache.has(fileId)) {
return fileUrlCache.get(fileId);
}
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// Same native-endpoint scheme requirement as fetchFileState() above -- 'Token', not 'Bearer'.
if (token) {
headers.Authorization = 'Token ' + token;
}
const promise = fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers}).then(res => {
if (!res.ok) {
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
}
return res.blob();
}).then(blob => URL.createObjectURL(blob)).catch(err => {
fileUrlCache.delete(fileId);
throw err;
});
fileUrlCache.set(fileId, promise);
return promise;
}
// -- Groups (Ch.6). --
export const getGroups = ({category = 'joined', query = '', cursor = ''} = {}) => imGet('/groups' + qs({category, query, cursor}));
export const createGroup = ({name, description = '', memberUserIds = [], avatarFileId = ''}) =>
imPost('/groups', {name, description, member_user_ids: memberUserIds, avatar_file_id: avatarFileId});
export const getGroup = topic => imGet('/groups/' + encodeURIComponent(topic));
export const updateGroup = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic), body);
export const getGroupSettings = topic => imGet('/groups/' + encodeURIComponent(topic) + '/settings');
export const updateGroupMemberSettings = (topic, {pinned, mute_notifications}) =>
imPatch('/groups/' + encodeURIComponent(topic) + '/member-settings', {pinned, mute_notifications});
export const updateMyGroupProfile = (topic, nickname, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/members/me/profile', {nickname, version});
export const leaveGroup = topic => imPost('/groups/' + encodeURIComponent(topic) + '/leave', {});
export const getGroupMembers = (topic, {query = '', cursor = '', role = '', eligibleRole = '', mentionable, muteEligible} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' +
qs({query, cursor, role, eligible_role: eligibleRole, mentionable: mentionable ? 'true' : '', mute_eligible: muteEligible ? 'true' : ''}));
export const createGroupQRCode = (topic, expiresInSeconds = 86400) =>
imPost('/groups/' + encodeURIComponent(topic) + '/qrcodes', {expires_in_seconds: expiresInSeconds});
export const inviteGroupMembers = (topic, userIds, message = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/invitations', {user_ids: userIds, message});
export const acceptGroupInvitation = invitationId => imPost('/group-invitations/' + encodeURIComponent(invitationId) + '/accept', {});
export const createGroupJoinRequest = (topic, {message = '', source = 'qr', qrToken = ''} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests', {message, source, qr_token: qrToken});
export const getGroupJoinRequests = (topic, status = 'pending', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/join-requests' + qs({status, cursor}));
export const acceptGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
// Server struct (imGroupJoinRequestActionRequest) only has `Version` -- no reason field exists, and the
// business layer hardcodes an empty reject-reason even internally, so there's nothing to send here. Every
// JSON body goes through decodeIMJSONBody's DisallowUnknownFields(), so including an extra `reason` key
// (as an earlier draft of this function did) makes every single call 400 regardless of its value.
export const rejectGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const getGroupSummary = topic => imGet('/groups/' + encodeURIComponent(topic) + '/summary');
export const getLatestGroupAnnouncement = topic => imGet('/groups/' + encodeURIComponent(topic) + '/announcements/latest');
export const updateCurrentGroupAnnouncement = (topic, content, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/announcements/current', {content, version});
export const getGroupAnnouncement = (topic, announcementId) =>
imGet('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId));
export const readGroupAnnouncement = (topic, announcementId) =>
imPost('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId) + '/read', {});
export const getGroupGovernance = topic => imGet('/groups/' + encodeURIComponent(topic) + '/governance');
export const updateGroupGovernance = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic) + '/governance', body);
export const getGroupRoleLimits = topic => imGet('/groups/' + encodeURIComponent(topic) + '/role-limits');
export const addGroupAdmins = (topic, userIds, version) => imPost('/groups/' + encodeURIComponent(topic) + '/admins/batch', {user_ids: userIds, version});
// Must merge into idem()'s headers, not replace them (imDelete/imPost's `{headers: idem(), ...opts}`
// lets a same-key `opts.headers` clobber the whole object) -- the same Idempotency-Key-goes-missing
// bug this codebase already found and fixed once in disableYouthMode.
export const deleteGroupAdmin = (topic, userId, version) =>
imDelete('/groups/' + encodeURIComponent(topic) + '/admins/' + encodeURIComponent(userId), version ? {headers: {...idem(), 'If-Match': String(version)}} : {});
export const muteGroupMembers = (topic, members, reason = '') => imPost('/groups/' + encodeURIComponent(topic) + '/mutes', {members, reason});
export const getGroupMutes = (topic, cursor = '') => imGet('/groups/' + encodeURIComponent(topic) + '/mutes' + qs({cursor}));
export const unmuteGroupMember = (topic, userId) => imDelete('/groups/' + encodeURIComponent(topic) + '/mutes/' + encodeURIComponent(userId));
export const transferGroupOwner = (topic, targetUserId, version, actionToken = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/owner-transfer', {target_user_id: targetUserId, version},
actionToken ? {headers: {...idem(), 'X-Action-Token': actionToken}} : {});
// Real backend reads the search text from `q`, not `query` (confirmed against ListFiles's real
// query-param handling, same bug class as getOrganizationMembers) -- the search box in Group Files
// was silently a no-op.
export const getGroupFiles = (topic, {query = '', type = '', cursor = ''} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/files' + qs({q: query, type, cursor}));
// All fields besides file_id are `omitempty` server-side, so omitting them (as an earlier draft of this
// function did) never errors -- it just registers a file with a blank name and 0 size in the files list.
export const addGroupFile = (topic, {fileId, fileName = '', mimeType = '', fileType = '', fileSize = 0, sourceTopic = '', sourceSeq} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/files', {
file_id: fileId, file_name: fileName, mime_type: mimeType, file_type: fileType, file_size: fileSize,
source_topic: sourceTopic, source_seq: sourceSeq
});
export const deleteGroupFile = (topic, groupFileId) => imDelete('/groups/' + encodeURIComponent(topic) + '/files/' + encodeURIComponent(groupFileId));
export const getGroupMemberProfile = (topic, userId) => imGet('/groups/' + encodeURIComponent(topic) + '/members/' + encodeURIComponent(userId) + '/profile');
// -- Organization / workbench (Ch.9). --
export const getWorkbench = () => imGet('/workbench');
export const getOrganizationDepartments = (parentId = 'root', cursor = '') => imGet('/organization/departments' + qs({parent_id: parentId, cursor}));
export const getOrganizationDepartmentMembers = (departmentId, cursor = '') =>
imGet('/organization/departments/' + encodeURIComponent(departmentId) + '/members' + qs({cursor}));
// Real backend only reads `q` (server/im_http.go), never `query` -- confirmed against
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
// list every time).
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = ''} = {}) =>
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor}));
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
imGet('/organization/search' + qs({q: query, types, cursor}));
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
export const createMeeting = body => imPost('/meetings', body);
export const getMeeting = meetingId => imGet('/meetings/' + encodeURIComponent(meetingId));
export const updateMeeting = (meetingId, body) => imPatch('/meetings/' + encodeURIComponent(meetingId), body);
export const cancelMeeting = (meetingId, version, reason = '时间冲突') => imPost('/meetings/' + encodeURIComponent(meetingId) + '/cancel', {version, reason});
export const sendMeetingReminders = meetingId => imPost('/meetings/' + encodeURIComponent(meetingId) + '/reminders', {});
export const respondMeeting = (meetingId, response, version) => imPut('/meetings/' + encodeURIComponent(meetingId) + '/my-response', {response, version});
// Note: the server only reads scope/from/to/status/cursor -- 'date' and 'department_id' are
// silently ignored (confirmed against server/im_http.go), so a single-day filter must be
// expressed as from==to, and there is no way to scope this list to one department.
export const getDailyReports = ({scope = 'mine', from = '', to = '', status = '', cursor = ''} = {}) =>
imGet('/reports/daily' + qs({scope, from, to, status, cursor}));
export const createDailyReport = reportDate => imPost('/reports/daily', {report_date: reportDate});
export const getDailyReport = reportId => imGet('/reports/daily/' + encodeURIComponent(reportId));
export const updateDailyDraft = (reportId, content, version, attachments = []) =>
imPut('/reports/daily/' + encodeURIComponent(reportId) + '/draft', {content, attachments, version});
export const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {version});
// department_id is also ignored here -- statistics are always tenant-wide.
export const getDailyStatistics = date => imGet('/reports/daily/statistics' + qs({date}));
export const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {date, user_ids: userIds});
export const getMonthlyReports = ({scope = 'mine', fromMonth = '', toMonth = '', status = '', cursor = ''} = {}) =>
imGet('/reports/monthly' + qs({scope, from_month: fromMonth, to_month: toMonth, status, cursor}));
export const createMonthlyReport = month => imPost('/reports/monthly', {month});
export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeURIComponent(reportId));
export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) =>
imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version});
export const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version});
export const reopenMonthlyReport = (reportId, version, reason = '补充内容') =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version, reason});
// department_id is ignored here too -- statistics are always tenant-wide.
export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
export const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
// -- Personal settings (Ch.10). --
export const getMeProfile = () => imGet('/me/profile');
export const updateMeProfile = body => imPatch('/me/profile', body);
export const getMePrivacy = () => imGet('/me/privacy');
export const updateMePrivacy = body => imPatch('/me/privacy', body);
export const getYouthMode = () => imGet('/settings/youth-mode');
export const enableYouthMode = (policyVersion, actionToken) => imPost('/settings/youth-mode/enable', {policy_version: policyVersion, action_token: actionToken});
// imPost spreads `opts` last, so passing `headers` directly would silently replace (not merge
// with) the Idempotency-Key header from idem() -- confirmed live: the server actually enforces
// it ("Idempotency-Key is required", 400), unlike the earlier research pass's assumption that a
// missing key here was harmless. Merge explicitly instead of letting the spread clobber it.
export const disableYouthMode = actionToken => imPost('/settings/youth-mode/disable', {}, {headers: {...idem(), 'X-Action-Token': actionToken}});
export const getLatestAppRelease = ({platform = 'android', currentVersion = '1.0.0', channel = 'stable'} = {}) =>
imGet('/app/releases/latest' + qs({platform, current_version: currentVersion, channel}));
export const updateDevicePushToken = (deviceId, body) => imPut('/devices/' + encodeURIComponent(deviceId) + '/push-token', body);
// -- Reports & feedback (Ch.11). --
export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/reasons' + qs({target_type: targetType}));
export const submitAbuseReport = body => imPost('/reports/abuse', body);
export const getFeedbackConfig = () => imGet('/feedback/config');
export const submitFeedback = body => imPost('/feedback', body);
+29
View File
@@ -1,5 +1,17 @@
// Utility class for hash navigation. // Utility class for hash navigation.
// Every `navigateTo` call assigns `location.hash`, which the browser already records as a normal
// history entry -- `history.back()` would land on whatever the user actually saw before, for free.
// The product's "back" buttons never used that: each page hardcodes a single destination view name
// (e.g. Profile's `back={friend ? 'friend-profile' : 'chat-standard'}`), which is only ever right for
// the one entry point its author had in mind. Reach the same page from a second place (search result
// vs. QR scan vs. a banner inside an open chat) and "back" throws you at that guessed destination
// instead of retracing your actual steps. `goBack()` below is the fix: count real in-app navigations
// this page load and prefer `history.back()` whenever there's one to return to, only falling back to
// the old hardcoded guess when there truly isn't (fresh load, deep link, or a page refresh -- this
// counter is in-memory and intentionally does not survive those).
let navigationCount = 0;
export default class HashNavigation { export default class HashNavigation {
// Parse hash as in http://www.example.com/path#hash as if it were // Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments. // path and arguments.
@@ -24,9 +36,26 @@ export default class HashNavigation {
} }
static navigateTo(url) { static navigateTo(url) {
navigationCount++;
window.location.hash = url; window.location.hash = url;
} }
// Real "back": retraces the hash the user was actually on before, via native browser history,
// rather than a page's own guess at where it must have been reached from. Returns whether it
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
// fallback (a page's `back`/`backUid`/`backTopic` props) for the one case this can't cover: a
// fresh load, deep link, or refresh, where there's no in-app history yet to go back to (this
// counter is in-memory by design and does not survive those) -- callers keep using that fallback
// unchanged, just gated behind `if (!HashNavigation.goBack()) { ...existing fallback... }`.
static goBack() {
if (navigationCount > 0) {
navigationCount--;
window.history.back();
return true;
}
return false;
}
static composeUrlHash(path, params) { static composeUrlHash(path, params) {
let url = path.join('/'); let url = path.join('/');
const args = []; const args = [];
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
// Many product-prototype views fetch their data with `useState(null)` + a `useEffect` that calls an
// im-api.js getter. Every one of those unmounts when the user switches to a different sidepanel/tab
// and remounts from scratch on the way back -- React state doesn't survive that, so the screen goes
// blank (or shows a "加载中" placeholder) again while the same request that already succeeded once
// this session re-runs. Reported directly: switching between pages feels like it reloads every time.
//
// This hook keeps a module-level cache (survives unmount/remount, cleared only by a real page reload)
// keyed by whatever string the caller passes in, so a remounted view can paint its last-known-good
// data immediately while a fresh request updates it in the background -- the request still always
// happens, this only removes the empty flash while waiting for it.
const cache = new Map();
// `fetcher` is called on every mount/dep-change (this is not a TTL cache -- there's no staleness
// window, it always revalidates), but the *first* render after mount can use the previous result
// for this key instead of null. `key` should already encode whatever varies the result (e.g. include
// a user id or a "mine"/"team" flag) -- this module doesn't know how to derive that itself.
export function useCachedData(key, fetcher, deps) {
const [data, setData] = React.useState(() => (key ? cache.get(key) : undefined));
const [loading, setLoading] = React.useState(() => !!key && !cache.has(key));
React.useEffect(() => {
if (!key) return;
let live = true;
// Don't flip back to a loading state just because deps changed while we already have a cached
// value for the new key -- only the truly-never-fetched case should show a loading affordance.
setLoading(!cache.has(key));
fetcher().then(result => {
if (!live) return;
cache.set(key, result);
setData(result);
setLoading(false);
}).catch(() => {
// Keep whatever stale/cached value was already showing rather than clearing it on a transient
// failure -- a blank screen on error is worse than a slightly-out-of-date one.
if (live) setLoading(false);
});
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ...(deps || [])]);
return [data, loading];
}
// For the (rarer) case a view wants to seed its own useState from the cache directly -- e.g. when it
// needs to merge the cached value with other local state instead of holding it as the sole source of
// truth. Does not itself revalidate; pair it with writeCachedData once a fresh fetch resolves.
export function readCachedData(key) {
return key ? cache.get(key) : undefined;
}
export function writeCachedData(key, value) {
if (key) cache.set(key, value);
}
+16 -1
View File
@@ -3,6 +3,21 @@
import { Tinode } from 'tinode-sdk'; import { Tinode } from 'tinode-sdk';
import { WALLPAPER_DEFAULTS } from '../config.js'; import { WALLPAPER_DEFAULTS } from '../config.js';
// The backend's REST group layer writes a group's display name into its native Tinode `public`
// blob under a plain `name` key instead of the protocol-standard `fn` the SDK (and every other
// Tinode client) expects -- confirmed live against a freshly created group: `topic.public` comes
// back as `{name:'...', version:1}`, no `fn` at all. P2P/self topics are unaffected (their `public`
// really does carry `fn`). Until the backend writes `fn` correctly, every place that shows a
// topic's name has to fall back to `public.name` specifically for groups, or every group in the
// app displays as "未命名"/"未命名会话" (unnamed) -- this happened in the chat header, the
// conversation list, and the contact-list widget (used by member/forward pickers) before this.
export function topicDisplayName(topicName, publicData, fallback) {
const fn = publicData && publicData.fn;
if (fn) return fn;
if (publicData && Tinode.isGroupTopicName(topicName) && publicData.name) return publicData.name;
return fallback;
}
// Make shortcut icon appear with a green dot + show unread count in title. // Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) { export function updateFavicon(count) {
const oldIcon = document.getElementById('shortcut-icon'); const oldIcon = document.getElementById('shortcut-icon');
@@ -215,7 +230,7 @@ export function deliveryMarker(received) {
return { name: 'access_time' }; // watch face return { name: 'access_time' }; // watch face
case Tinode.MESSAGE_STATUS_FAILED: case Tinode.MESSAGE_STATUS_FAILED:
case Tinode.MESSAGE_STATUS_FATAL: case Tinode.MESSAGE_STATUS_FATAL:
return { name: 'warning', color: 'danger-color' }; // yellow icon /!\ return { name: 'warning', color: 'red' }; // red icon /!\
case Tinode.MESSAGE_STATUS_SENT: case Tinode.MESSAGE_STATUS_SENT:
return { name: 'done' }; // checkmark return { name: 'done' }; // checkmark
case Tinode.MESSAGE_STATUS_RECEIVED: case Tinode.MESSAGE_STATUS_RECEIVED:
+394 -105
View File
@@ -1,6 +1,11 @@
import React from 'react'; import React from 'react';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword
} from '../lib/im-api.js';
const USERNAME_RE = /^[a-z0-9]{1,10}$/; const USERNAME_RE = /^[a-z0-9]{1,10}$/;
const PHONE_RE = /^\d{11}$/; const PHONE_RE = /^\d{11}$/;
@@ -23,7 +28,7 @@ function AppMark({tenantName}) {
function Header({title, back = 'tenant'}) { function Header({title, back = 'tenant'}) {
return <header className="auth-header"> return <header className="auth-header">
<button type="button" className="auth-icon-button" onClick={() => navigate(back)} aria-label="返回"> <button type="button" className="auth-icon-button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回">
<Icon>arrow_back_ios_new</Icon> <Icon>arrow_back_ios_new</Icon>
</button> </button>
<h1>{title}</h1> <h1>{title}</h1>
@@ -65,21 +70,42 @@ function Toast({message, onDone}) {
return message ? <div className="auth-toast" role="status"><Icon>check_circle</Icon>{message}</div> : null; return message ? <div className="auth-toast" role="status"><Icon>check_circle</Icon>{message}</div> : null;
} }
function TenantEntry({onTenantSelected}) { // Shared alert for errors coming back from a real API/WS call (as opposed to local form validation,
// which each form already reports inline per-field).
function FormAlert({text}) {
return text ? <div className="auth-form-alert"><Icon>error_outline</Icon>{text}</div> : null;
}
function TenantEntry({onTenantResolved}) {
const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || ''); const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || '');
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = e => { const submit = async e => {
e.preventDefault(); e.preventDefault();
const value = code.trim(); const value = code.trim();
if (!value || value.length > 64) { if (!value || value.length > 64) {
setError(value ? '企业码不能超过 64 个字符' : '请输入企业码'); setError(value ? '企业码不能超过 64 个字符' : '请输入企业码');
return; return;
} }
sessionStorage.setItem('im-tenant-code', value); setBusy(true);
sessionStorage.setItem('im-tenant-name', value === 'acme' ? '示例企业' : value); setError('');
onTenantSelected(value); try {
navigate('login'); const data = await resolveTenant(value);
// Kept for the mock UI's own display of the tenant name while on this screen; the durable
// context (read by every subsequent /v1/im/* call and by the {hi} tenant field) is persisted
// by onTenantResolved.
sessionStorage.setItem('im-tenant-code', data.tenant.code || value);
sessionStorage.setItem('im-tenant-name', data.tenant.name || data.tenant.code || value);
onTenantResolved(data, value);
navigate('login');
} catch (err) {
// Per the product spec, tenant-resolve failures are shown as one generic message
// regardless of reason (not found vs. disabled vs. rate limited).
setError('企业码不存在或已停用,请核实后重试');
} finally {
setBusy(false);
}
}; };
return <div className="auth-landing"> return <div className="auth-landing">
@@ -88,46 +114,57 @@ function TenantEntry({onTenantSelected}) {
<AppMark /> <AppMark />
<Field value={code} onChange={e => { setCode(e.target.value); setError(''); }} <Field value={code} onChange={e => { setCode(e.target.value); setError(''); }}
placeholder="请输入企业码" autoComplete="organization" maxLength="64" error={error} autoFocus /> placeholder="请输入企业码" autoComplete="organization" maxLength="64" error={error} autoFocus />
<button className="auth-primary" type="submit" disabled={!code.trim()}>进入企业</button> <button className="auth-primary" type="submit" disabled={!code.trim() || busy}>{busy ? '进入中…' : '进入企业'}</button>
</form> </form>
<button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button> <button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button>
</div>; </div>;
} }
function Login({tenantName}) { function Login({tenantName, login, loginDisabled, persist, errorText, errorLevel, onLoginRequest, onPersistenceChange, onError}) {
const [account, setAccount] = React.useState(''); const [account, setAccount] = React.useState(login || '');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const [agreed, setAgreed] = React.useState(false); const [agreed, setAgreed] = React.useState(false);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState(''); // The prototype (docs/images/登录页面.png) has no "stay logged in" checkbox -- a consumer IM app
// just keeps you signed in. Opt in silently instead of exposing the old debug-era toggle.
React.useEffect(() => {
if (!persist) {
onPersistenceChange(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Real login failures (wrong password, disabled account, ...) clear the password and keep the account,
// matching the product spec.
React.useEffect(() => {
if (errorLevel === 'err' && errorText) {
setPassword('');
}
}, [errorText, errorLevel]);
const submit = e => { const submit = e => {
e.preventDefault(); e.preventDefault();
if (!account.trim() || !password || !agreed) return; if (!account.trim() || !password || !agreed || loginDisabled) return;
if (password === 'error') { onLoginRequest(account.trim(), password);
setPassword('');
setError('账号或密码错误');
} else {
setToast('登录成功');
window.setTimeout(() => navigate('messages-home'), 500);
}
}; };
return <div className="auth-landing auth-login-page"> return <div className="auth-landing auth-login-page">
<button type="button" className="auth-back-link" onClick={() => navigate('tenant')}><Icon>arrow_back_ios_new</Icon>切换企业</button> <button type="button" className="auth-back-link"
onClick={() => { onError && onError(); navigate('tenant'); }}><Icon>arrow_back_ios_new</Icon>切换企业</button>
<form className="auth-landing-form" onSubmit={submit}> <form className="auth-landing-form" onSubmit={submit}>
<AppMark tenantName={tenantName} /> <AppMark tenantName={tenantName} />
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null} <FormAlert text={errorLevel === 'err' ? errorText : ''} />
<Field value={account} onChange={e => { setAccount(e.target.value); setError(''); }} <Field value={account} onChange={e => setAccount(e.target.value)}
placeholder="请输入用户名/手机号" autoComplete="username" /> placeholder="请输入用户名/手机号" autoComplete="username" />
<PasswordField value={password} onChange={e => { setPassword(e.target.value); setError(''); }} <PasswordField value={password} onChange={e => setPassword(e.target.value)}
placeholder="请输入密码" autoComplete="current-password" /> placeholder="请输入密码" autoComplete="current-password" />
<div className="auth-row auth-row-end"><button type="button" className="auth-text-button" onClick={() => navigate('reset')}>忘记密码</button></div> <div className="auth-row auth-row-end"><button type="button" className="auth-text-button" onClick={() => navigate('reset')}>忘记密码</button></div>
<button className="auth-primary" type="submit" disabled={!account.trim() || !password || !agreed}>登录</button> <button className="auth-primary" type="submit" disabled={!account.trim() || !password || !agreed || loginDisabled}>
{loginDisabled ? '登录中…' : '登录'}
</button>
<p className="auth-switch">没有账户? <button type="button" onClick={() => navigate('register')}>马上注册</button></p> <p className="auth-switch">没有账户? <button type="button" onClick={() => navigate('register')}>马上注册</button></p>
</form> </form>
<Agreement checked={agreed} onChange={setAgreed} /> <Agreement checked={agreed} onChange={setAgreed} />
<Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
@@ -136,7 +173,9 @@ function EnterpriseRegister() {
const [email, setEmail] = React.useState(''); const [email, setEmail] = React.useState('');
const [file, setFile] = React.useState(null); const [file, setFile] = React.useState(null);
const [errors, setErrors] = React.useState({}); const [errors, setErrors] = React.useState({});
const [submitted, setSubmitted] = React.useState(sessionStorage.getItem('im-enterprise-application-status') === 'received'); const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const [reviewDays, setReviewDays] = React.useState(1);
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]); React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]);
@@ -149,31 +188,52 @@ function EnterpriseRegister() {
return; return;
} }
if (file) URL.revokeObjectURL(file.url); if (file) URL.revokeObjectURL(file.url);
setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected)}); setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected), blob: selected});
setErrors({...errors, file: ''}); setErrors({...errors, file: ''});
}; };
const submit = e => { const submit = async e => {
e.preventDefault(); e.preventDefault();
const next = {}; const next = {};
if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码'; if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码';
if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱'; if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱';
setErrors(next); setErrors(next);
if (Object.keys(next).length) return; if (Object.keys(next).length) return;
setSubmitted(true);
sessionStorage.setItem('im-enterprise-application-status', 'received'); setSubmitting(true);
setToast('提交成功,我们会尽快联系你'); try {
let licenseFileId = '';
if (file) {
const uploaded = await uploadPublicFile(file.blob, 'enterprise_license');
licenseFileId = uploaded.file_id;
}
const data = await submitTenantApplication({
credit_code: creditCode,
enterprise_email: email,
license_file_id: licenseFileId,
tenant_code_hint: sessionStorage.getItem('im-tenant-code') || ''
});
// Prototype (docs/images/企业注册-填写注册信息.png) states this in business days, not hours.
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
setSubmitted(true);
setToast('提交成功,我们会尽快联系你');
} catch (err) {
setErrors({...next, form: err.message || '提交失败,请稍后重试'});
} finally {
setSubmitting(false);
}
}; };
return <div className="auth-page"> return <div className="auth-page">
<Header title="企业注册" /> <Header title="企业注册" />
<main className="auth-content auth-content-narrow"> <main className="auth-content auth-content-narrow">
<ol className="auth-steps"> <ol className="auth-steps">
<li className="active"><span>1</span><b>填写企业信息</b></li> <li className={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li>
<li><span>2</span><b>确定信息</b></li> <li><span>2</span><b>确定信息</b></li>
<li><span>3</span><b>提交审核</b></li> <li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
</ol> </ol>
<form className="auth-form" onSubmit={submit}> <form className="auth-form" onSubmit={submit}>
<FormAlert text={errors.form} />
<label className="auth-label">统一社会信用代码</label> <label className="auth-label">统一社会信用代码</label>
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))} <Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} /> placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} />
@@ -193,187 +253,397 @@ function EnterpriseRegister() {
<Field icon="mail_outline" value={email} onChange={e => setEmail(e.target.value)} <Field icon="mail_outline" value={email} onChange={e => setEmail(e.target.value)}
placeholder="请输入企业邮箱" type="email" error={errors.email} /> placeholder="请输入企业邮箱" type="email" error={errors.email} />
<p className="auth-help">用于接收审核结果及企业通知</p> <p className="auth-help">用于接收审核结果及企业通知</p>
<button className="auth-primary" type="submit" disabled={submitted || creditCode.length !== 18}>{submitted ? '已提交' : '提交资料'}</button> <button className="auth-primary" type="submit" disabled={submitted || submitting || creditCode.length !== 18}>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 1 个工作日完成审核</p> {submitted ? '已提交' : submitting ? '提交中…' : '提交资料'}
</button>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 {reviewDays} 个工作日完成审核</p>
</form> </form>
</main> </main>
<Toast message={toast} onDone={() => setToast('')} /> <Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
// Per docs/images/企业注册-问题反馈.png: titled "意见反馈", 300-char textarea with the instructions
// as its placeholder (not a separate label), "图片(N/9)" counter-style label, and no contact field.
function EnterpriseFeedback() { function EnterpriseFeedback() {
const [description, setDescription] = React.useState(''); const [description, setDescription] = React.useState('');
const [contact, setContact] = React.useState('');
const [files, setFiles] = React.useState([]); const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
const addFiles = e => { const addFiles = e => {
const selected = Array.from(e.target.files || []).slice(0, 9 - files.length); const selected = Array.from(e.target.files || []).slice(0, 9 - files.length);
const valid = selected.filter(file => file.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)); const valid = selected.filter(file => file.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(file.type));
setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file)})))); setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file), blob: file}))));
}; };
const submit = e => { const submit = async e => {
e.preventDefault(); e.preventDefault();
const length = description.trim().length; const length = description.trim().length;
if (length < 10 || length > 1000) { if (length < 10 || length > 300) {
setError('问题描述需为 10 至 1000 个字符'); setError('问题描述需为 10 至 300 个字符');
return; return;
} }
setToast('已收到反馈,我们会尽快处理'); setSubmitting(true);
files.forEach(item => URL.revokeObjectURL(item.url)); try {
setDescription(''); setContact(''); setFiles([]); const screenshotFileIds = [];
for (const item of files) {
const uploaded = await uploadPublicFile(item.blob, 'tenant_entry_feedback');
screenshotFileIds.push(uploaded.file_id);
}
await submitTenantEntryFeedback({
description: description.trim(),
tenant_code_hint: sessionStorage.getItem('im-tenant-code') || '',
screenshot_file_ids: screenshotFileIds
});
setToast('已收到反馈,我们会尽快处理');
files.forEach(item => URL.revokeObjectURL(item.url));
setDescription(''); setFiles([]); setError('');
} catch (err) {
setError(err.message || '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
}; };
return <div className="auth-page auth-soft-bg"> return <div className="auth-page auth-soft-bg">
<Header title="问题反馈" /> <Header title="意见反馈" />
<main className="auth-content auth-content-narrow"> <main className="auth-content auth-content-narrow">
<form className="auth-form" onSubmit={submit}> <form className="auth-form" onSubmit={submit}>
<label className="auth-label">问题描述</label> <label className="auth-label">问题反馈</label>
<textarea value={description} maxLength="1000" onChange={e => { setDescription(e.target.value); setError(''); }} <textarea value={description} maxLength="300" onChange={e => { setDescription(e.target.value); setError(''); }}
placeholder="请详细描述遇到的问题,至少输入10个字" className={error ? 'invalid' : ''} /> placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" className={error ? 'invalid' : ''} />
<div className="auth-counter">{description.length}/1000</div> <div className="auth-counter">{description.length}/300</div>
{error ? <div className="auth-field-error">{error}</div> : null} {error ? <div className="auth-field-error">{error}</div> : null}
<label className="auth-label">上传截图 <span>最多 9 张</span></label> <label className="auth-label">图片({files.length}/9)</label>
<div className="auth-screenshot-grid"> <div className="auth-screenshot-grid">
{files.map((file, index) => <div className="auth-screenshot" key={file.url}> {files.map((file, index) => <div className="auth-screenshot" key={file.url}>
<img src={file.url} alt={file.name} /> <img src={file.url} alt={file.name} />
<button type="button" onClick={() => { URL.revokeObjectURL(file.url); setFiles(files.filter((_, i) => i !== index)); }} aria-label="删除截图"><Icon>close</Icon></button> <button type="button" onClick={() => { URL.revokeObjectURL(file.url); setFiles(files.filter((_, i) => i !== index)); }} aria-label="删除截图"><Icon>close</Icon></button>
</div>)} </div>)}
{files.length < 9 ? <label className="auth-add-screenshot"><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={addFiles} /><Icon>add</Icon></label> : null} {files.length < 9 ? <label className="auth-add-screenshot"><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={addFiles} /><Icon>photo_camera</Icon><span>点击上传</span></label> : null}
</div> </div>
<label className="auth-label">联系方式 <span>选填</span></label> <button className="auth-primary" type="submit" disabled={description.trim().length < 10 || submitting}>{submitting ? '提交中…' : '提交'}</button>
<Field value={contact} onChange={e => setContact(e.target.value)} placeholder="手机号或邮箱,便于我们联系您" />
<button className="auth-primary" type="submit" disabled={description.trim().length < 10}>提交反馈</button>
</form> </form>
</main> </main>
<Toast message={toast} onDone={() => setToast('')} /> <Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
function VerificationButton({phone, scene = 'register'}) { // Requests a phone verification code (scene: 'register' | 'reset_password' | 'security') and reports the
// resulting challenge_id upward -- the caller needs it to verify the code the user types in.
function VerificationButton({phone, scene = 'register', onChallenge, onError}) {
const [seconds, setSeconds] = React.useState(0); const [seconds, setSeconds] = React.useState(0);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!seconds) return undefined; if (!seconds) return undefined;
const timer = window.setTimeout(() => setSeconds(seconds - 1), 1000); const timer = window.setTimeout(() => setSeconds(s => s - 1), 1000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [seconds]); }, [seconds]);
return <button type="button" className="auth-code-button" disabled={seconds > 0 || !PHONE_RE.test(phone)}
onClick={() => { sessionStorage.setItem('im-verification-scene', scene); setSeconds(60); }}> const click = async () => {
{seconds > 0 ? `${seconds}s` : '获取验证码'} if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
setBusy(true);
try {
const data = await requestVerificationCode(scene, phone);
onChallenge(data.challenge_id);
setSeconds(data.resend_after_seconds || 60);
} catch (err) {
onError && onError(err.message || '获取验证码失败');
if (err.details && err.details.retry_after_seconds) {
setSeconds(err.details.retry_after_seconds);
}
if (err.details && err.details.challenge_id) {
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
onChallenge(err.details.challenge_id);
}
} finally {
setBusy(false);
}
};
return <button type="button" className="auth-code-button" disabled={busy || seconds > 0 || !PHONE_RE.test(phone)} onClick={click}>
{seconds > 0 ? `${seconds}s` : busy ? '发送中…' : '获取验证码'}
</button>; </button>;
} }
function Register() { // Per the actual prototype screenshots (docs/images/注册页面.png), all fields are shown together --
// no phone-vs-username mode toggle. "Phone takes precedence" (per the spec text) is validation-only:
// if a phone is filled it must be verified; the invite code is only required for a username-only
// signup (matching the mock's original submit-time validation, now with real backend calls behind it).
function Register({errorText, errorLevel, onImRegisterRequest}) {
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''}); const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
const [challengeId, setChallengeId] = React.useState('');
const [agreed, setAgreed] = React.useState(false); const [agreed, setAgreed] = React.useState(false);
const [errors, setErrors] = React.useState({}); const [errors, setErrors] = React.useState({});
const [toast, setToast] = React.useState(''); const [submitting, setSubmitting] = React.useState(false);
const change = key => e => setForm({...form, [key]: e.target.value}); const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => { const usingPhone = !!form.phone;
// onImRegisterRequest (tinode-web.jsx's handleImRegisterRequest -> a WS connect+createAccountBasic
// chain) isn't awaited and doesn't return a promise -- on the username/invite-code path (no
// verifyRegisterCode await ahead of it) `submit` returns and its `finally` clears `submitting`
// within milliseconds, long before that WS round-trip finishes, leaving the button re-clickable
// and able to fire a second concurrent registration. The parent's only failure signal is these
// props changing, so hold `submitting` until either that happens or the phone-path's own catch
// already handled it (success unmounts this component regardless).
React.useEffect(() => {
if (errorLevel === 'err' && errorText) setSubmitting(false);
}, [errorText, errorLevel]);
const submit = async e => {
e.preventDefault(); e.preventDefault();
const next = {}; const next = {};
if (!form.username && !form.phone) next.username = '用户名和手机号至少填写一项'; if (!form.username && !form.phone) next.username = '用户名和手机号至少填写一项';
if (form.username && !USERNAME_RE.test(form.username)) next.username = '用户名仅支持 1-10 位小写字母或数字'; if (form.username && !USERNAME_RE.test(form.username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
if (form.phone && !PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号'; if (usingPhone) {
if (!form.phone && !INVITE_RE.test(form.invite)) next.invite = '用户名注册需填写 5 位字母邀请码'; if (!PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
if (form.invite && !INVITE_RE.test(form.invite)) next.invite = '邀请码为 5 位大小写字母'; if (!challengeId) next.code = '请先获取验证码';
else if (!/^\d{6}$/.test(form.code)) next.code = '请输入 6 位验证码';
} else if (!INVITE_RE.test(form.invite)) {
next.invite = '用户名注册需填写 5 位字母邀请码';
}
if (form.password.length < 8) next.password = '密码至少 8 位'; if (form.password.length < 8) next.password = '密码至少 8 位';
setErrors(next); setErrors(next);
if (Object.keys(next).length) return; if (Object.keys(next).length) return;
setToast(form.phone ? '注册信息已准备,接口将在联调阶段启用' : '未绑定手机号,忘记密码需联系企业管理员');
setSubmitting(true);
try {
if (usingPhone) {
const verified = await verifyRegisterCode(challengeId, form.code);
// The username field is optional even on the phone path -- filling in both used to silently
// drop the typed username (always sent '' and always logged in as the phone), because the
// server only auto-assigns a random handle when username is actually empty
// (server/user.go resolveRegistrationAccount: username!="" && phone!="" => account=username,
// secretAccount=username; only username=="" falls back to the phone-derived random handle).
// Per the protocol spec, the phone number itself must never become the display name -- leave
// it blank when no username was typed either (server assigns a random handle; the user can
// set a nickname afterwards).
const secretAccount = form.username || ('+86' + form.phone);
onImRegisterRequest(secretAccount, form.password, form.username || '',
{username: form.username || '', phone: form.phone, invite_code: ''},
{meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}});
} else {
onImRegisterRequest(form.username, form.password, form.username,
{username: form.username, phone: '', invite_code: form.invite}, null);
}
// On success the parent (tinode-web.jsx handleImRegisterRequest -> handleLoginSuccessful)
// navigates away (unmounting this component, so `submitting` no longer matters); on failure
// it surfaces errorText/errorLevel, which the effect above clears `submitting` on. No
// `finally` here on purpose -- onImRegisterRequest isn't awaited (it's not a promise), so a
// `finally` would clear `submitting` within milliseconds on the username/invite-code path,
// long before that request's own WS round-trip resolves, defeating the double-submit guard.
} catch (err) {
setErrors({...next, code: err.message || '验证码错误或已过期'});
setSubmitting(false);
}
}; };
return <div className="auth-landing auth-register-page"> return <div className="auth-landing auth-register-page">
<button type="button" className="auth-back-link" onClick={() => navigate('login')}><Icon>arrow_back_ios_new</Icon>返回登录</button>
<form className="auth-landing-form auth-register-form" onSubmit={submit}> <form className="auth-landing-form auth-register-form" onSubmit={submit}>
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} /> <AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} />
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})} <Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
placeholder="请输入用户名" error={errors.username} autoComplete="username" /> placeholder="请输入用户名" error={errors.username} autoComplete="username" />
<Field value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})} <Field value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})}
placeholder="请输入手机号" error={errors.phone} inputMode="numeric" /> placeholder="请输入手机号" error={errors.phone} inputMode="numeric" />
<Field value={form.code} onChange={change('code')} placeholder="请输入短信验证码" inputMode="numeric" <Field value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})}
action={<VerificationButton phone={form.phone} />} /> placeholder="请输入短信验证码" inputMode="numeric" error={errors.code}
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
onError={msg => setErrors(e => ({...e, code: msg}))} />} />
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} /> <PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})} <Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
placeholder="请输入邀请码(非必填)" error={errors.invite} /> placeholder="请输入邀请码(非必填)" error={errors.invite} />
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed}>注册</button> <button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'}
</button>
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p> <p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
</form> </form>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" /> <Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
<Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
function PasswordReset() { function PasswordReset() {
const [form, setForm] = React.useState({phone: '', code: '', password: '', confirm: ''}); const [form, setForm] = React.useState({phone: '', code: '', password: '', confirm: ''});
const [challengeId, setChallengeId] = React.useState('');
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
const change = key => e => setForm({...form, [key]: e.target.value}); const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => {
const submit = async e => {
e.preventDefault(); e.preventDefault();
if (!PHONE_RE.test(form.phone)) return setError('请输入 11 位手机号'); if (!PHONE_RE.test(form.phone)) return setError('请输入 11 位手机号');
if (form.password.length < 8 || form.password.length > 20) return setError('密码需为 8-20 位字母或数字组合'); if (!challengeId || !/^\d{6}$/.test(form.code)) return setError('请先获取并输入 6 位验证码');
// Matches Register's rule (>= 8, no upper bound -- the backend's own checkPasswordPolicy only
// enforces a minimum length). This page used to also reject anything over 20 characters, a limit
// invented here with no backend counterpart: a password set during registration could exceed
// it, then resetting it back to that same value through this flow would be rejected -- a
// self-inflicted lockout for password reset's own sake.
if (form.password.length < 8) return setError('密码至少 8 位');
if (form.password !== form.confirm) return setError('两次输入的密码不一致'); if (form.password !== form.confirm) return setError('两次输入的密码不一致');
setError(''); setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('login'), 1200); setSubmitting(true);
setError('');
try {
const verified = await verifyPasswordResetCode(challengeId, form.code);
await confirmPasswordReset(verified.reset_token, form.password);
setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('login'), 1200);
} catch (err) {
setError(err.message || '验证码错误或已过期');
setSubmitting(false);
}
}; };
return <div className="auth-page auth-soft-bg"> return <div className="auth-page auth-soft-bg">
<Header title="忘记密码" back="login" /> <Header title="忘记密码" back="login" />
<main className="auth-content auth-content-narrow auth-reset-content"> <main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={submit}> <form className="auth-list-form" onSubmit={submit}>
<label><span>手机号</span><input value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})} placeholder="请输入手机号" inputMode="numeric" /></label> <label><span>手机号</span><input value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})} placeholder="请输入手机号" inputMode="numeric" /></label>
<label><span>验证码</span><input value={form.code} onChange={change('code')} placeholder="请输入验证码" inputMode="numeric" /><VerificationButton phone={form.phone} scene="reset_password" /></label> <label><span>验证码</span><input value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})} placeholder="请输入验证码" inputMode="numeric" />
<VerificationButton phone={form.phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} /></label>
<div className="auth-list-gap" /> <div className="auth-list-gap" />
<label><span>设置密码</span><input type="password" value={form.password} onChange={change('password')} placeholder="8-20位字母或数字组合" /></label> <label><span>设置密码</span><input type="password" value={form.password} onChange={change('password')} placeholder="至少 8 位" /></label>
<label><span>再次输入</span><input type="password" value={form.confirm} onChange={change('confirm')} placeholder="请再次确定密码" /></label> <label><span>再次输入</span><input type="password" value={form.confirm} onChange={change('confirm')} placeholder="请再次确定密码" /></label>
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null} {error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
<button className="auth-primary auth-sticky-action" type="submit" disabled={!form.phone || !form.code || !form.password || !form.confirm}>修改密码</button> <button className="auth-primary auth-sticky-action" type="submit"
disabled={!form.phone || !form.code || !form.password || !form.confirm || submitting}>
{submitting ? '提交中…' : '修改密码'}
</button>
</form> </form>
</main> </main>
<Toast message={toast} onDone={() => setToast('')} /> <Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
// Precedes 1.11 (setting the independent password): resolves the account's own verified phone
// server-side (POST /v1/im/security/challenges never takes a phone number from the client) and, on
// successful verify, stashes the resulting action_token for the next screen to spend.
const IDENTITY_CHECK_SCENE = 'set_independent_password';
function IdentityCheck({authenticated}) { function IdentityCheck({authenticated}) {
const [phone] = React.useState('138****8899'); const [phoneMask, setPhoneMask] = React.useState('');
const [challengeId, setChallengeId] = React.useState('');
const [code, setCode] = React.useState(''); const [code, setCode] = React.useState('');
const [toast, setToast] = React.useState(''); const [seconds, setSeconds] = React.useState(0);
return <div className="auth-page auth-soft-bg"> const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState('');
React.useEffect(() => {
if (!seconds) return undefined;
const timer = window.setTimeout(() => setSeconds(s => s - 1), 1000);
return () => window.clearTimeout(timer);
}, [seconds]);
const requestCode = React.useCallback(async () => {
setBusy(true);
setError('');
try {
const data = await createSecurityChallenge(IDENTITY_CHECK_SCENE);
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
setSeconds(data.resend_after_seconds || 60);
} catch (err) {
setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败'));
} finally {
setBusy(false);
}
}, []);
React.useEffect(() => { requestCode(); }, [requestCode]);
const submit = async e => {
e.preventDefault();
if (!challengeId || code.length !== 6 || busy) return;
setBusy(true);
try {
const data = await verifySecurityChallenge(challengeId, code);
sessionStorage.setItem('im-action-token:' + IDENTITY_CHECK_SCENE, JSON.stringify(data));
navigate('independent-password');
} catch (err) {
setError(err.message || '验证码错误');
} finally {
setBusy(false);
}
};
return <div className="auth-page auth-soft-bg auth-identity-check-page">
<Header title="身份验证" back={authenticated ? 'security' : 'login'} /> <Header title="身份验证" back={authenticated ? 'security' : 'login'} />
<main className="auth-content auth-content-narrow auth-reset-content"> <main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={e => { e.preventDefault(); setToast('身份验证成功'); }}> <form className="auth-list-form" onSubmit={submit}>
<label><span>手机号</span><input value={phone} readOnly /></label> <label><span>手机号</span><input value={phoneMask} readOnly placeholder="获取中…" /></label>
<label><span>验证码</span><input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="请输入验证码" inputMode="numeric" /><VerificationButton phone="13800008899" scene="security" /></label> <label><span>验证码</span>
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6}>确定</button> <input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="请输入验证码" inputMode="numeric" />
<button type="button" className="auth-code-button" disabled={busy || seconds > 0} onClick={requestCode}>
{seconds > 0 ? `${seconds}s` : '获取验证码'}
</button>
</label>
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6 || busy}>确定</button>
</form> </form>
</main> </main>
<Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
function Realname({verified = false, authenticated = false}) { function Realname({authenticated = false}) {
const [name, setName] = React.useState(verified ? '蒋欣' : ''); // Real status (unverified/pending/verified/rejected) drives the view -- the old mock's `verified`
const [idNumber, setIdNumber] = React.useState(verified ? '43993838581999355454' : ''); // prop toggle is gone, both 'realname' and 'realname-verified' routes render this and let the
const [status, setStatus] = React.useState(verified ? 'verified' : 'unverified'); // GET /v1/im/security/realname response decide what to show.
const [status, setStatus] = React.useState('loading');
const [maskedName, setMaskedName] = React.useState('');
const [maskedIdNumber, setMaskedIdNumber] = React.useState('');
const [name, setName] = React.useState('');
const [idNumber, setIdNumber] = React.useState('');
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const submit = e => { const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
getRealnameStatus().then(data => {
setStatus(data.status);
setMaskedName(data.masked_name || '');
setMaskedIdNumber(data.masked_id_number || '');
}).catch(err => { setError(err.message || '加载实名状态失败'); setStatus('load_error'); });
}, []);
const editable = status === 'unverified' || status === 'rejected';
const submit = async e => {
e.preventDefault(); e.preventDefault();
if (name.trim().length < 2 || !/^\d{17}[\dXx]$/.test(idNumber)) { if (name.trim().length < 2 || !/^\d{17}[\dXx]$/.test(idNumber)) {
setError('请输入有效姓名和 18 位身份证号码'); return; setError('请输入有效姓名和 18 位身份证号码'); return;
} }
setStatus('pending'); setError(''); setBusy(true);
try {
// Backend's validIMRealName only accepts the literal 'cn_id' -- 'id_card' was rejected outright
// with a 400 every time, so real-name submission never actually worked.
const data = await submitRealname({name: name.trim(), id_type: 'cn_id', id_number: idNumber.toUpperCase()});
setStatus(data.status || 'pending');
setError('');
} catch (err) {
setError(err.message || '提交失败,请稍后重试');
} finally {
setBusy(false);
}
}; };
if (status === 'loading' || status === 'load_error') {
return <div className="auth-page auth-realname-page"><Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<p className="auth-privacy-note" style={{margin: '2rem 1rem'}}>{status === 'load_error' ? (error || '加载失败,请重试') : '加载中…'}</p>
</div>;
}
return <div className="auth-page auth-realname-page"> return <div className="auth-page auth-realname-page">
<Header title="实名认证" back={authenticated ? 'security' : 'login'} /> <Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div> <div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
<main className="auth-realname-content"> <main className="auth-realname-content">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2> <h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
<form onSubmit={submit}> <form onSubmit={submit}>
<label><span>姓名</span><input value={status === 'verified' ? name[0] + '*' : name} onChange={e => setName(e.target.value)} readOnly={status !== 'unverified'} placeholder="请输入真实姓名" /></label> <label><span>姓名</span><input value={editable ? name : maskedName} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={status === 'verified' ? idNumber.slice(0, 3) + '***********' + idNumber.slice(-4) : idNumber} onChange={e => setIdNumber(e.target.value)} readOnly={status !== 'unverified'} placeholder="请输入证件号码" /></label> <label><span>证件号码</span><input value={editable ? idNumber : maskedIdNumber} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
{error ? <div className="auth-field-error">{error}</div> : null} {error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" type="submit" disabled={status !== 'unverified'}>{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : '提交'}</button> <button className="auth-primary" type="submit" disabled={!editable || busy}>
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
</button>
</form> </form>
<p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p> <p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p>
</main> </main>
@@ -384,7 +654,7 @@ function RealnameReminder() {
return <div className="auth-page auth-reminder-demo"> return <div className="auth-page auth-reminder-demo">
<div className="auth-demo-chat"><h1>消息</h1><div className="auth-demo-search"><Icon>search</Icon>搜索</div>{['系统消息', '文件助手', '项目协作群', '陈经理'].map((item, i) => <div className="auth-demo-row" key={item}><span>{item[0]}</span><div><b>{item}</b><small>{i ? '最近一条消息内容' : '安全通知与账号动态'}</small></div></div>)}</div> <div className="auth-demo-chat"><h1>消息</h1><div className="auth-demo-search"><Icon>search</Icon>搜索</div>{['系统消息', '文件助手', '项目协作群', '陈经理'].map((item, i) => <div className="auth-demo-row" key={item}><span>{item[0]}</span><div><b>{item}</b><small>{i ? '最近一条消息内容' : '安全通知与账号动态'}</small></div></div>)}</div>
<div className="auth-modal-backdrop"> <div className="auth-modal-backdrop">
<div className="auth-modal"><h2>实名认证提醒</h2><p>根据国家法律法规要求,您需先进行实名认证才能使用该应用</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button><button className="auth-text-button" onClick={() => navigate('tenant')}>稍后处理</button></div> <div className="auth-modal"><h2>实名认证提醒</h2><p>根据国家法律法规要求,您需先进行实名认证才能使用该项 APP</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button></div>
</div> </div>
</div>; </div>;
} }
@@ -393,11 +663,25 @@ function IndependentPassword({authenticated}) {
const [pin, setPin] = React.useState(''); const [pin, setPin] = React.useState('');
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
const [busy, setBusy] = React.useState(false);
// Mirrors server/im_http.go validIMIndependentPassword: 4 digits, not all-same, not a run.
const invalid = ['0000','1111','2222','3333','4444','5555','6666','7777','8888','9999','0123','1234','2345','3456','4567','5678','6789','9876','8765','7654','6543','5432','4321','3210']; const invalid = ['0000','1111','2222','3333','4444','5555','6666','7777','8888','9999','0123','1234','2345','3456','4567','5678','6789','9876','8765','7654','6543','5432','4321','3210'];
const submit = e => { const submit = async e => {
e.preventDefault(); e.preventDefault();
if (invalid.includes(pin)) { setError('请勿使用连续或重复数字'); return; } if (invalid.includes(pin)) { setError('请勿使用连续或重复数字'); return; }
setError(''); setToast('独立密码设置成功'); setPin(''); const stored = sessionStorage.getItem('im-action-token:' + IDENTITY_CHECK_SCENE);
const actionToken = stored && JSON.parse(stored).action_token;
if (!actionToken) { setError('身份验证已过期,请返回重新验证'); return; }
setBusy(true);
try {
await setIndependentPassword(pin, 'youth_mode', actionToken);
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
setError(''); setToast('独立密码设置成功'); setPin('');
} catch (err) {
setError(err.message || '设置失败,请返回重新验证身份');
} finally {
setBusy(false);
}
}; };
return <div className="auth-page"> return <div className="auth-page">
<Header title="" back={authenticated ? 'security' : 'login'} /> <Header title="" back={authenticated ? 'security' : 'login'} />
@@ -406,26 +690,31 @@ function IndependentPassword({authenticated}) {
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div> <div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus /> <input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
{error ? <div className="auth-field-error">{error}</div> : null} {error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" disabled={pin.length !== 4} onClick={submit}>确定</button> <button className="auth-primary" disabled={pin.length !== 4 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
</main> </main>
<Toast message={toast} onDone={() => setToast('')} /> <Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
export default function AuthOnboardingView({view, onTenantSelected, authenticated = false}) { export default function AuthOnboardingView({
view, onTenantResolved, authenticated = false,
login, loginDisabled, persist, errorText, errorLevel,
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
}) {
const tenantName = sessionStorage.getItem('im-tenant-name') || ''; const tenantName = sessionStorage.getItem('im-tenant-name') || '';
switch (view) { switch (view) {
case 'tenant': return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />; case 'tenant': return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
case 'login': return <Login tenantName={tenantName} />; case 'login': return <Login tenantName={tenantName} login={login} loginDisabled={loginDisabled} persist={persist}
errorText={errorText} errorLevel={errorLevel} onLoginRequest={onLoginRequest} onPersistenceChange={onPersistenceChange} onError={onError} />;
case 'enterprise-register': return <EnterpriseRegister />; case 'enterprise-register': return <EnterpriseRegister />;
case 'enterprise-feedback': return <EnterpriseFeedback />; case 'enterprise-feedback': return <EnterpriseFeedback />;
case 'register': return <Register />; case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
case 'reset': return <PasswordReset />; case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} />; case 'identity-check': return <IdentityCheck authenticated={authenticated} />;
case 'realname': return <Realname authenticated={authenticated} />; case 'realname': return <Realname authenticated={authenticated} />;
case 'realname-verified': return <Realname verified authenticated={authenticated} />; case 'realname-verified': return <Realname authenticated={authenticated} />;
case 'realname-reminder': return <RealnameReminder />; case 'realname-reminder': return <RealnameReminder />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} />; case 'independent-password': return <IndependentPassword authenticated={authenticated} />;
default: return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />; default: return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
} }
} }
+427 -62
View File
@@ -1,9 +1,22 @@
import React from 'react'; import React from 'react';
import QRCode from 'qrcodejs'; import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
blockUser, unblockUser, getFileUrl, getBootstrap
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js';
const navigate = view => HashNavigation.navigateTo(view); // Carries an optional `uid` (and anything else already in the hash) across chapter-5 navigation --
// e.g. address-book -> friend-profile -> friend-settings all need to know which person this is.
const navigate = (view, uid) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = uid ? HashNavigation.addUrlParam(hash, 'uid', uid) : HashNavigation.removeUrlParam(hash, 'uid');
HashNavigation.navigateTo(hash);
};
export const CONTACT_MANAGEMENT_VIEWS = [ export const CONTACT_MANAGEMENT_VIEWS = [
'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-search-result', 'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-search-result',
@@ -12,27 +25,39 @@ export const CONTACT_MANAGEMENT_VIEWS = [
'clear-chat-confirm' 'clear-chat-confirm'
]; ];
const FRIENDS = [ // Server's own `initial` (bootstrap.go displayInitial) is just the first Unicode code point of the
{id: 'annie', name: '安妮', remark: '安妮 · 产品', initial: 'A', avatar: 'img/bkg/p09.jpg', account: 'AN1024'}, // display name -- for Chinese names that's a hanzi, not a Latin letter, so it can't drive the A/B/C
{id: 'chen', name: '陈晨', initial: 'C', avatar: 'img/bkg/p05.jpg', account: 'CHEN88'}, // section headers docs/images/通讯录.png actually shows. Group client-side by first-pinyin-letter.
{id: 'lizi', name: '栗子来了', remark: '栗子', initial: 'L', avatar: 'img/bkg/p15.jpg', account: 'LIZI66'}, function alphaInitial(name) {
{id: 'wang', name: '王珊珊', initial: 'W', avatar: 'img/bkg/p14.jpg', account: 'WSS2026'} const ch = (name || '').trim()[0] || '#';
]; if (/[A-Za-z]/.test(ch)) {
return ch.toUpperCase();
const REQUESTS = [ }
{id: 'r1', name: '祈万事安好', message: '我是设计交流群里的小祈', time: '10:24', status: 'pending', avatar: 'img/bkg/p03.jpg'}, if (/[一-龥]/.test(ch)) {
{id: 'r2', name: '一颗栗子', message: '你好,想认识一下', time: '昨天', status: 'accepted', avatar: 'img/bkg/p15.jpg'}, const letter = pinyin(ch, {pattern: 'first', toneType: 'none'});
{id: 'r3', name: '林间风', message: '通过手机号搜索到你', time: '星期一', status: 'rejected', avatar: 'img/bkg/p18.jpg'} return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#';
]; }
return '#';
}
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; } function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', right, onRight}) { function Header({title, back = 'messages-home', backUid, right, onRight}) {
return <header className="people-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>; return <header className="people-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
} }
// `person.avatar_file_id` (real API field) is loaded lazily via a cached authenticated fetch --
// file serving is auth-gated, there's no public URL a plain <img src> could point at directly.
function Avatar({person, icon}) { function Avatar({person, icon}) {
return <span className="people-avatar">{person && person.avatar ? <img src={person.avatar} alt="" /> : <Icon>{icon || 'person'}</Icon>}</span>; const fileId = person && person.avatar_file_id;
const [url, setUrl] = React.useState(null);
React.useEffect(() => {
if (!fileId) { setUrl(null); return undefined; }
let live = true;
getFileUrl(fileId).then(u => { if (live) setUrl(u); }).catch(() => {});
return () => { live = false; };
}, [fileId]);
return <span className="people-avatar">{url ? <img src={url} alt="" /> : <Icon>{icon || 'person'}</Icon>}</span>;
} }
function Toast({message, onDone}) { function Toast({message, onDone}) {
@@ -44,94 +69,434 @@ function Toast({message, onDone}) {
return message ? <div className="chat-toast" role="status">{message}</div> : null; return message ? <div className="chat-toast" role="status">{message}</div> : null;
} }
function Search({value, onChange, placeholder}) { function Search({value, onChange, placeholder, onSubmit}) {
return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter' && onSubmit) onSubmit(); }} placeholder={placeholder} /></label>;
} }
function QrCard({group = false}) { // `payload` is the real `qr_payload` string from POST /me/qrcodes (already shaped like
// `im://qr?token=...&purpose=add_friend` -- same scheme the demo literal used, no format change).
function QrCard({payload, profile, loading}) {
const ref = React.useRef(null); const ref = React.useRef(null);
React.useEffect(() => { React.useEffect(() => {
if (!ref.current) return; if (!ref.current || !payload) return;
ref.current.innerHTML = ''; ref.current.innerHTML = '';
new QRCode(ref.current, {text: group ? 'im://qr?token=demo-group-2026&purpose=join_group' : 'im://qr?token=demo-user-2026&purpose=add_friend', width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M}); new QRCode(ref.current, {text: payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
}, [group]); }, [payload]);
return <section className="people-qr-card"><div className="people-qr-profile"><Avatar person={group ? {avatar: 'img/bkg/p22.jpg'} : FRIENDS[1]} /><span><strong>{group ? '产品研发群' : '陈晨'}</strong><small>{group ? '18 位成员' : '账号 CHEN88'}</small></span></div><div className="people-qrcode" ref={ref} /><p>{group ? '扫码申请加入群聊' : '扫一扫上面的二维码,加我为好友'}</p><small>二维码 24 小时内有效</small></section>; return <section className="people-qr-card">
<div className="people-qr-profile"><Avatar person={profile} /><span><strong>{(profile && profile.display_name) || ''}</strong>
<small>{profile && profile.public_account ? '账号 ' + profile.public_account : ''}</small></span></div>
<div className="people-qrcode" ref={ref} />
{loading ? <p>正在生成二维码…</p> : <p>扫一扫上面的二维码,加我为好友</p>}
<small>二维码 24 小时内有效</small>
</section>;
} }
function AddressBook() { function AddressBook({myUserId, onTopicSelected}) {
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const visible = FRIENDS.filter(item => `${item.name}${item.remark || ''}`.includes(query)); // Seeded from cache so re-opening the address book (very much a back-and-forth flow: check
const groups = visible.reduce((result, item) => ({...result, [item.initial]: (result[item.initial] || []).concat(item)}), {}); // contacts, open a chat, come back) doesn't drop straight back to "加载中…" for a friend list that
// hasn't actually changed.
// Keyed by myUserId (same convention as the 'bootstrap:'+myUserId cache just below) -- a bare
// 'friends' key meant logging out and into a *different* account in the same tab would briefly
// paint the previous account's cached friend list on mount, before the fresh getFriends() call
// overwrote it moments later.
const friendsCacheKey = myUserId ? 'friends:' + myUserId : null;
const [friends, setFriends] = React.useState(() => (friendsCacheKey && readCachedData(friendsCacheKey)) || []);
const [pendingCount, setPendingCount] = React.useState(0);
const [loading, setLoading] = React.useState(() => !(friendsCacheKey && readCachedData(friendsCacheKey)));
// Same bootstrap the message center already fetches (and caches under the same key) -- only need
// it here for the file assistant's real topic, so a plain read of the shared cache is enough.
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
const fileAssistantTopic = bootstrap && bootstrap.entries && bootstrap.entries.file_assistant
&& bootstrap.entries.file_assistant.topic;
React.useEffect(() => {
getFriends().then(data => {
const list = data.items || [];
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
setFriends(list);
}).catch(() => {}).finally(() => setLoading(false));
getFriendRequests('inbound', 'pending').then(data => setPendingCount((data.items || []).length)).catch(() => {});
}, []);
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.includes(query));
const groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name);
return {...result, [key]: (result[key] || []).concat(item)};
}, {});
const sortedKeys = Object.keys(groups).sort();
const entries = [ const entries = [
{icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: 1, tone: 'blue'}, {icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: pendingCount || null, tone: 'blue'},
{icon: 'group', label: '群聊', route: 'group-list', tone: 'green'}, {icon: 'group', label: '群组', route: 'group-list', tone: 'green'},
{icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange'}, {icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange', fileAssistant: true},
{icon: 'notifications', label: '系统消息', route: 'system-messages', tone: 'red'} {icon: 'notifications', label: '系统消息', route: 'system-messages', tone: 'red'}
]; ];
return <div className="people-page"><Header title="通讯录" right="添加" onRight={() => navigate('add-friend')} /><Search value={query} onChange={setQuery} placeholder="搜索联系人" /><main className="people-scroll"><section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => navigate(item.route)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>{Object.keys(groups).map(initial => <section className="people-group" key={initial}><h2>{initial}</h2>{groups[initial].map(person => <button type="button" key={person.id} onClick={() => navigate('friend-profile')}><Avatar person={person} /><span><strong>{person.remark || person.name}</strong>{person.remark ? <small>{person.name}</small> : null}</span></button>)}</section>)}</main><nav className="people-index">{Object.keys(groups).map(value => <a href={`#people-${value}`} key={value}>{value}</a>)}</nav></div>; const scrollTo = key => {
const el = document.getElementById('people-group-' + key);
if (el) el.scrollIntoView({behavior: 'smooth', block: 'start'});
};
// The file assistant is a real topic (from bootstrap), not a route -- same bug/fix pattern as the
// "常联系的人" shortcuts in message-center-view.jsx. Falls back to the old demo route only if
// bootstrap hasn't resolved a real topic yet, so the entry never becomes a dead click.
const openEntry = item => {
if (item.fileAssistant && fileAssistantTopic && onTopicSelected) onTopicSelected(fileAssistantTopic);
else navigate(item.route);
};
return <div className="people-page"><Header title="通讯录" right="添加" onRight={() => navigate('add-friend')} /><Search value={query} onChange={setQuery} placeholder="搜索联系人" />
<main className="people-scroll">
<section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => openEntry(item)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>
<h3 className="people-section-title">好友通讯录</h3>
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">暂无好友</p> : null}
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark ? <small>{person.display_name}</small> : null}</span>
</button>)}
</section>)}
</main>
<nav className="people-index">{sortedKeys.map(value => <a href="#" key={value} onClick={e => { e.preventDefault(); scrollTo(value); }}>{value}</a>)}</nav>
</div>;
}
function requestStatusLabel(item) {
if (item.status === 'pending') return item.direction === 'outbound' ? '已发送' : null;
if (item.status === 'accepted') return '已添加';
if (item.status === 'rejected') return '已拒绝';
if (item.status === 'expired') return '已过期';
return null;
} }
function FriendRequests({onToast}) { function FriendRequests({onToast}) {
const [items, setItems] = React.useState(REQUESTS); const [items, setItems] = React.useState([]);
const handle = (id, status) => {setItems(values => values.map(item => item.id === id ? {...item, status} : item)); onToast(status === 'accepted' ? '已通过好友申请' : '已拒绝好友申请');}; const [loading, setLoading] = React.useState(true);
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">{items.map(item => <article key={item.id}><Avatar person={item} /><div><strong>{item.name}</strong><p>{item.message}</p><small>{item.time}</small></div>{item.status === 'pending' ? <span><button type="button" onClick={() => handle(item.id, 'rejected')}>拒绝</button><button type="button" className="primary" onClick={() => handle(item.id, 'accepted')}>通过</button></span> : <em>{item.status === 'accepted' ? '已通过' : '已拒绝'}</em>}</article>)}</main></div>; React.useEffect(() => {
getFriendRequests('all', 'all').then(data => setItems(data.items || [])).catch(() => {}).finally(() => setLoading(false));
}, []);
const handle = (item, accept) => {
const nextStatus = accept ? 'accepted' : 'rejected';
setItems(values => values.map(v => v.request_id === item.request_id ? {...v, status: nextStatus} : v));
const call = accept ? acceptFriendRequest(item.request_id) : rejectFriendRequest(item.request_id);
call.then(() => onToast(accept ? '已通过好友申请' : '已拒绝好友申请')).catch(err => {
setItems(values => values.map(v => v.request_id === item.request_id ? {...v, status: item.status} : v));
onToast(err.message || '操作失败');
});
};
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">
{loading ? <p className="people-empty-hint">加载中…</p> : items.length === 0 ? <p className="people-empty-hint">暂无新朋友申请</p> : null}
{items.map(item => <article key={item.request_id}>
<Avatar person={item.peer} />
<div><strong>{(item.peer && item.peer.display_name) || item.applicant_id}</strong><p>{item.message || '请求添加您为好友'}</p><small>{new Date(item.created_at).toLocaleString()}</small></div>
{item.status === 'pending' && item.direction === 'inbound' ?
<span><button type="button" onClick={() => handle(item, false)}>拒绝</button><button type="button" className="primary" onClick={() => handle(item, true)}>通过验证</button></span> :
<em>{requestStatusLabel(item)}</em>}
</article>)}
</main></div>;
} }
function AddFriend({result = false}) { // `GET /users/{id}/profile` (getUserProfile) 403s for anyone who isn't already a friend, by design
const [query, setQuery] = React.useState(result ? 'AN1024' : ''); // (server's default `profile_visibility` is "friends"-only, server/im_http.go's imPrivacyAllowsProfile).
return <div className="people-page"><Header title="添加好友" back="address-book" /><Search value={query} onChange={setQuery} placeholder="输入账号或手机号" /><main>{query ? <section className="people-search-result"><h2>搜索结果</h2><button type="button" onClick={() => navigate('group-member-profile')}><Avatar person={FRIENDS[0]} /><span><strong>安妮</strong><small>账号 AN1024 · 产品设计组</small></span><em>添加</em></button></section> : <section className="people-add-actions"><button type="button"><Icon>qr_code_scanner</Icon><span><strong>扫一扫</strong><small>扫描好友或群二维码</small></span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('my-friend-qr')}><Icon>qr_code_2</Icon><span><strong>我的二维码</strong><small>让朋友扫一扫添加我</small></span><Icon>chevron_right</Icon></button><button type="button"><Icon>share</Icon><span><strong>分享好友</strong><small>发送我的个人名片</small></span><Icon>chevron_right</Icon></button></section>}<p className="people-my-account">我的账号:CHEN88</p></main></div>; // But the two places that hand you a *stranger* to look at -- an account search hit and a scanned
// QR code -- already returned the exact same fields (display_name/avatar_file_id/gender/region/
// public_account/signature, both go through userSearchItemPayload/copyOptionalProfileFields) *before*
// that gate, unconditionally. Profile used to throw that object away and re-fetch via the gated
// endpoint, which 403s and leaves the page stuck on "加载中…" forever with no way forward -- this
// stashes what we already have so Profile can render immediately, and only uses getUserProfile as a
// best-effort background refresh (for when the two really are/become friends, or the peer's privacy
// setting is "everyone").
function seedProfile(item) {
if (!item || !item.user_id) return;
writeCachedData('profile-seed:' + item.user_id, {
...item,
capabilities: {can_add_friend: !!item.can_add}
});
}
function AddFriend({result = false, onToast, onOpenScanner}) {
const [query, setQuery] = React.useState('');
const [searching, setSearching] = React.useState(false);
const [searched, setSearched] = React.useState(result);
const [results, setResults] = React.useState([]);
const runSearch = q => {
if (!q.trim()) { setSearched(false); setResults([]); return; }
setSearching(true); setSearched(true);
searchUsers(q.trim(), 'exact_account').then(data => setResults(data.items || [])).catch(() => setResults([])).finally(() => setSearching(false));
};
const shareCard = () => {
createMyQRCode().then(data => {
const text = `加我为好友:${data.qr_payload}`;
if (navigator.share) {
navigator.share({title: '添加好友', text}).catch(() => {});
} else if (navigator.clipboard) {
navigator.clipboard.writeText(text);
}
}).catch(() => {});
};
const actionLabel = item => item.relationship === 'friend' ? '已是好友' :
item.relationship === 'pending_outbound' ? '已申请' : item.relationship === 'pending_inbound' ? '待通过' : '添加';
return <div className="people-page"><Header title="添加好友" back="address-book" />
<Search value={query} onChange={v => { setQuery(v); if (!v.trim()) { setSearched(false); setResults([]); } }} placeholder="查找账号" onSubmit={() => runSearch(query)} />
<main>
{searched ? <section className="people-search-result"><h2>查找人</h2>
{searching ? <p className="people-empty-hint">搜索中…</p> : results.length === 0 ? <p className="people-empty-hint">未找到相关用户</p> :
results.map(item => <button type="button" key={item.user_id}
onClick={() => { seedProfile(item); item.relationship === 'friend' ? navigate('friend-profile', item.user_id) : navigate('group-member-profile', item.user_id); }}>
<Avatar person={item} /><span><strong>{item.display_name}</strong></span>
<em className={item.relationship === 'none' ? 'people-add-cta' : ''}>{actionLabel(item)}</em>
</button>)}
</section> : <section className="people-add-actions">
<button type="button" onClick={() => navigate('my-friend-qr')}><Icon>qr_code</Icon><span><strong>我的二维码</strong><small>让朋友扫一扫添加我</small></span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => onOpenScanner ? onOpenScanner() : onToast('暂不支持扫码,请使用账号搜索添加好友')}><Icon>qr_code_scanner</Icon><span><strong>扫一扫</strong><small>扫描好友或群二维码</small></span><Icon>chevron_right</Icon></button>
<button type="button" onClick={shareCard}><Icon>share</Icon><span><strong>分享给好友</strong><small>分享链接给好友,一起来玩吧</small></span><Icon>chevron_right</Icon></button>
</section>}
</main>
</div>;
} }
function QrPage({onToast}) { function QrPage({onToast}) {
return <div className="people-page people-qr-page"><Header title="我的二维码" back="add-friend" /><main><QrCard /><div className="people-qr-actions"><button type="button" onClick={() => onToast('二维码名片已分享')}><Icon>share</Icon><span>分享好友</span></button><button type="button" onClick={() => onToast('二维码已保存到相册')}><Icon>download</Icon><span>保存到相册</span></button></div></main></div>; const [payload, setPayload] = React.useState(null);
const [profile, setProfile] = React.useState(null);
React.useEffect(() => {
createMyQRCode().then(data => { setPayload(data.qr_payload); setProfile(data.display_profile); }).catch(() => onToast('二维码生成失败'));
}, []);
const share = () => {
if (navigator.share && payload) {
navigator.share({title: '添加好友', text: payload}).then(() => onToast('二维码名片已分享')).catch(() => {});
} else if (navigator.clipboard && payload) {
navigator.clipboard.writeText(payload);
onToast('链接已复制');
}
};
const save = () => {
const canvas = document.querySelector('.people-qrcode canvas');
if (!canvas) { onToast('二维码尚未生成'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = '我的二维码.png';
a.click();
onToast('二维码已保存到相册');
};
return <div className="people-page people-qr-page"><Header title="我的二维码" back="add-friend" /><main><QrCard payload={payload} profile={profile} loading={!payload} />
<div className="people-qr-actions"><button type="button" onClick={share}><Icon>share</Icon><span>分享好友</span></button><button type="button" onClick={save}><Icon>download</Icon><span>保存到相册</span></button></div>
</main></div>;
} }
function Verification({onToast}) { function Verification({uid, onToast}) {
const [message, setMessage] = React.useState('我是产品研发群里的陈晨'); // Same seed-then-best-effort-refresh as Profile -- reached right after it (via its "添加好友"
return <div className="people-page"><Header title="好友验证" back="group-member-profile" /><main className="people-verify"><Avatar person={FRIENDS[0]} /><h2>安妮</h2><label><span>验证留言</span><textarea value={message} maxLength="50" onChange={event => setMessage(event.target.value)} /><small>{message.length}/50</small></label><button type="button" className="people-primary" disabled={!message.trim()} onClick={() => {onToast('好友申请已发送'); window.setTimeout(() => navigate('group-member-profile'), 800);}}>发送申请</button></main></div>; // button), so the cache this reads is the one Profile itself just kept warm.
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
const [message, setMessage] = React.useState('');
const [sending, setSending] = React.useState(false);
const [sent, setSent] = React.useState(false);
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(data => { writeCachedData('profile-seed:' + uid, data); setProfile(data); }).catch(() => {});
}, [uid]);
const alreadyPending = profile && (profile.relationship === 'pending_outbound' || profile.relationship === 'pending_inbound');
const alreadyFriend = profile && profile.relationship === 'friend';
const send = () => {
setSending(true);
createFriendRequest(uid, message.trim(), 'search').then(() => {
onToast('好友申请已发送'); setSent(true);
// Patch the seed Profile will re-read on remount -- its own background getUserProfile refresh
// can't do this for us, since a stranger-turned-pending peer still fails that gated endpoint
// the exact same way a stranger does (server only exempts an established "friend").
writeCachedData('profile-seed:' + uid, {...(readCachedData('profile-seed:' + uid) || {}), relationship: 'pending_outbound'});
window.setTimeout(() => navigate('group-member-profile', uid), 800);
}).catch(err => onToast(err.message || '发送失败')).finally(() => setSending(false));
};
return <div className="people-page"><Header title="好友验证" back="group-member-profile" backUid={uid} /><main className="people-verify">
<Avatar person={profile} /><h2>{(profile && profile.display_name) || ''}</h2>
{alreadyFriend ? <p className="people-empty-hint">你们已经是好友了</p> :
alreadyPending || sent ? <p className="people-empty-hint">好友申请正在等待验证</p> :
<label><span>验证留言</span><textarea value={message} maxLength="50" placeholder="我是祈万事安好" onChange={event => setMessage(event.target.value)} /><small>{message.length}/50</small></label>}
{alreadyFriend || alreadyPending || sent ? null : <div className="people-verify-actions">
<button type="button" onClick={() => navigate('group-member-profile', uid)}>取消</button>
<button type="button" className="people-primary" disabled={sending} onClick={send}>{sending ? '发送中…' : '发送'}</button>
</div>}
</main></div>;
} }
function Profile({friend, onToast}) { function Profile({uid, friend, onTopicSelected, onToast}) {
const person = friend ? FRIENDS[0] : FRIENDS[2]; const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} right={friend ? '设置' : null} onRight={() => navigate('friend-settings')} /><main className="people-profile"><section><Avatar person={person} /><div><h2>{person.remark || person.name}</h2><p>{person.remark ? `昵称:${person.name}` : '产品研发群成员'}</p><p>账号:{person.account}</p></div><Icon>qr_code_2</Icon></section><dl><div><dt>性别</dt><dd>女</dd></div><div><dt>地区</dt><dd>上海 浦东新区</dd></div><div><dt>个性签名</dt><dd>保持好奇,认真生活。</dd></div></dl>{friend ? <div className="people-profile-actions"><button type="button" className="people-primary" onClick={() => navigate('chat-standard')}><Icon>chat_bubble_outline</Icon>发消息</button><button type="button" onClick={() => navigate('call-history')}><Icon>call</Icon>音视频通话</button></div> : <button type="button" className="people-primary" onClick={() => navigate('friend-verification')}><Icon>person_add_alt</Icon>添加好友</button>}</main></div>; // A failed fetch with no seed to fall back on previously left this stuck on "加载中…" forever --
// tracked separately from `profile` itself (which legitimately stays null while genuinely still
// loading) so the render below can tell the two apart, same fix as DownloadPage's `loadError`.
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(data => {
writeCachedData('profile-seed:' + uid, data);
setProfile(data);
}).catch(() => {
// Expected for anyone who isn't a friend yet (server-side privacy default) -- if a search/QR
// seed already got us a usable preview, keep showing that instead of erroring the page out.
// Only a total stranger with no seed data at all (e.g. a stale/direct deep link) has nothing to
// show, and that's the one case worth telling the user about.
setProfile(current => {
if (!current) { onToast('资料加载失败'); setLoadError(true); }
return current;
});
});
}, [uid]);
if (!profile) return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} /><main className="people-profile"><p className="people-empty-hint">{loadError ? '资料加载失败,请重试' : '加载中…'}</p></main></div>;
const canAdd = profile.capabilities && profile.capabilities.can_add_friend;
const isFriend = profile.relationship === 'friend';
// canAdd===false covers several real, distinct relationships (found live: searching a friend who
// had blocked me showed this exact page claiming a request was "pending verification" when nothing
// had been sent and nothing ever could be -- they'd blocked me). Only pending_outbound actually
// matches that wording; the others need their own accurate text instead of reusing it as a catch-all.
const cannotAddReason = profile.relationship === 'blocked_by_peer' ? '对方已将你拉黑,无法添加为好友' :
profile.relationship === 'blocked_by_me' ? '你已拉黑对方,需先取消拉黑才能添加为好友' :
profile.relationship === 'pending_inbound' ? '对方已向你发送好友申请,请前往"新朋友"处理' :
'好友申请正在等待验证';
// Real native Tinode p2p topic name (from my side) is just the peer's own user_id ("usrXXXX") --
// NOT `profile.topic` (a separate "p2pXXXX" identifier the custom REST layer uses internally,
// which Tinode.topicType() doesn't recognize, so selecting it left the message pane blank).
const openChat = () => onTopicSelected(uid);
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'}
right={isFriend ? '设置' : null} onRight={() => navigate('friend-settings', uid)} />
<main className="people-profile">
<section><Avatar person={profile} /><div><h2>{profile.display_name}</h2>
{profile.public_account ? <p>用户名:{profile.public_account}</p> : null}
{profile.region ? <p>地区:{profile.region}</p> : null}</div><Icon>qr_code</Icon></section>
<dl>{profile.gender ? <div><dt>性别</dt><dd>{profile.gender}</dd></div> : null}
<div><dt>个性签名</dt><dd className={profile.signature ? '' : 'muted'}>{profile.signature || '此人很懒什么都没留下~'}</dd></div>
</dl>
{isFriend ? <div className="people-profile-actions">
<button type="button" className="people-primary" onClick={openChat}><Icon>chat_bubble_outline</Icon>发消息</button>
<button type="button" onClick={openChat}><Icon>call</Icon>音视频通话</button>
</div> :
canAdd ? <button type="button" className="people-primary" onClick={() => navigate('friend-verification', uid)}><Icon>person_add_alt</Icon>添加好友</button> :
<p className="people-empty-hint">{cannotAddReason}</p>}
</main>
</div>;
} }
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; } function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
function Settings({initialBlocked, onToast}) { function Settings({uid, initialBlocked, onToast}) {
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
// real, current friend (see seedProfile's own comment), which used to leave this page's header
// blank instead of just stuck like Profile, but no less wrong.
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
const [blocked, setBlocked] = React.useState(initialBlocked);
const [pinned, setPinned] = React.useState(false); const [pinned, setPinned] = React.useState(false);
const [muted, setMuted] = React.useState(false); const [muted, setMuted] = React.useState(false);
const [blocked, setBlocked] = React.useState(initialBlocked); const [editingRemark, setEditingRemark] = React.useState(false);
return <div className="people-page"><Header title="聊天设置" back="friend-profile" /><main className="people-settings"><section className="people-settings-profile"><Avatar person={FRIENDS[0]} /><strong>安妮 · 产品</strong></section><section><button type="button" onClick={() => navigate('history-search')}><span>查找聊天记录</span><Icon>chevron_right</Icon></button><button type="button"><span>消息保存方式</span><small>永久</small><Icon>chevron_right</Icon></button></section><section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div><div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div><div><span>加入黑名单</span><Toggle checked={blocked} onChange={value => value ? navigate('block-confirm') : (setBlocked(false), onToast('已移出黑名单'))} /></div></section><section><button type="button"><span>设置备注</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('abuse-report-reasons')}><span>投诉与举报</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('clear-chat-confirm')}><span>清除聊天记录</span><Icon>chevron_right</Icon></button></section><button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm')}>删除好友</button></main></div>; const [remark, setRemark] = React.useState('');
const [remarkDraft, setRemarkDraft] = React.useState('');
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {});
// GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always
// calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 --
// folding an empty remark into display_name instead of returning a separate field), only the
// friends list endpoint's friendListItemPayload does. Look this friend up there instead.
getFriends().then(data => {
const entry = (data.items || []).find(item => item.user_id === uid);
if (entry) setRemark(entry.remark || '');
}).catch(() => {});
}, [uid]);
const saveRemark = () => {
const value = remarkDraft.trim();
updateFriendRemark(uid, value).then(() => {
setRemark(value);
setEditingRemark(false); onToast('备注已更新');
}).catch(err => onToast(err.message || '备注更新失败'));
};
return <div className="people-page"><Header title="聊天设置" back="friend-profile" backUid={uid} /><main className="people-settings">
<section className="people-settings-profile"><Avatar person={profile} /><strong>{(profile && profile.display_name) || ''}</strong></section>
<section><button type="button" onClick={() => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid))}>
<span>查看聊天记录</span><Icon>chevron_right</Icon>
</button>
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
<section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div>
<div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div>
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
{editingRemark ?
<div className="people-remark-edit"><input value={remarkDraft} maxLength="20" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
<button type="button" onClick={() => { setRemarkDraft(remark); setEditingRemark(true); }}><span>备注</span>{remark ? <small>{remark}</small> : null}<Icon>chevron_right</Icon></button>}
<div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
if (value) { navigate('block-confirm', uid); return; }
unblockUser(uid).then(() => { setBlocked(false); onToast('已移出黑名单'); }).catch(err => onToast(err.message || '操作失败'));
}} /></div>
<button type="button" onClick={() => {
// The local navigate(view, uid) helper only carries a single `uid` param -- the report
// flow (security-support-view.jsx's ReportReasons/ReportDetails) needs target_type/
// target_id/target_name, so build the hash directly here rather than through it.
let hash = HashNavigation.setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'user');
hash = HashNavigation.addUrlParam(hash, 'target_id', uid);
hash = HashNavigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
HashNavigation.navigateTo(hash);
}}><span>投诉与举报</span><Icon>chevron_right</Icon></button>
</section>
{blocked ? null : <button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm', uid)}>删除好友</button>}
</main></div>;
} }
function BlockList({onToast}) { function BlockList({onToast}) {
const [people, setPeople] = React.useState([FRIENDS[3], FRIENDS[2]]); const [people, setPeople] = React.useState([]);
return <div className="people-page"><Header title="黑名单" back="friend-settings" /><main className="people-block-list">{people.length ? people.map(person => <article key={person.id}><Avatar person={person} /><span><strong>{person.name}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => {setPeople(items => items.filter(item => item.id !== person.id)); onToast('已移出黑名单');}}>移出</button></article>) : <div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}</main></div>; const [loading, setLoading] = React.useState(true);
React.useEffect(() => { getBlocks().then(data => setPeople(data.items || [])).catch(() => {}).finally(() => setLoading(false)); }, []);
const remove = userId => {
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
.catch(err => onToast(err.message || '操作失败'));
};
// The only entry point into this view is personal-settings-view.jsx's "设置" list
// ('android-settings'), not friend-settings -- this page has no friend uid in scope at all.
return <div className="people-page"><Header title="黑名单" back="android-settings" /><main className="people-block-list">
{loading ? <p className="people-empty-hint">加载中…</p> : people.length ?
people.map(item => <article key={item.user_id}><Avatar person={item.user} /><span><strong>{(item.user && item.user.display_name) || item.user_id}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => remove(item.user_id)}>移出</button></article>) :
<div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}
</main></div>;
} }
function ConfirmSheet({type, onToast}) { function ConfirmSheet({type, uid, tinode, onToast}) {
const [busy, setBusy] = React.useState(false);
const config = { const config = {
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除'}, delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除', keepUid: false},
block: {title: '加入黑名单', text: '加入黑名单后,你将不再收到对方的消息和通话。', action: '确认拉黑', route: 'friend-settings-blocked', toast: '已加入黑名单'}, block: {title: '黑名单', text: '拉黑后将不会收到对方的消息,可在"设置"—"黑名单"查看人员名单', action: '确定', route: 'friend-settings-blocked', toast: '已加入黑名单', keepUid: true},
clear: {title: '清除聊天记录', text: '仅清除你自己的聊天记录,不会影响对方,也不会删除已收藏内容。', action: '确认清除', route: 'friend-settings', toast: '聊天记录已清除'} clear: {title: '清除聊天记录', text: '清空后不可找回,确定要清空当前会话的历史记录吗?', action: '确定', route: 'friend-settings', toast: '聊天记录已清除', keepUid: true}
}[type]; }[type];
return <div className="people-page"><Settings initialBlocked={type === 'block'} onToast={onToast} /><div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p><button type="button" className="danger" onClick={() => {onToast(config.toast); window.setTimeout(() => navigate(config.route), 700);}}>{config.action}</button><button type="button" onClick={() => navigate('friend-settings')}>取消</button></section></div></div>; const confirm = () => {
setBusy(true);
let call;
if (type === 'delete') call = deleteFriend(uid);
else if (type === 'block') call = blockUser(uid);
else {
// No dedicated "clear my view of history" REST endpoint exists yet -- this is the same native
// Tinode soft-delete-all-for-me the real message-actions menu already uses (context-menu.jsx).
// Native p2p topic name (from my side) is just the peer's own user_id -- NOT the relationship
// context's `topic` field, which is a separate "p2pXXXX" id the REST layer uses internally and
// Tinode.topicType() doesn't recognize (see Profile.openChat for the full story).
const topic = tinode ? tinode.getTopic(uid) : null;
call = topic ? topic.delMessagesAll(false) : Promise.resolve();
}
call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); })
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
};
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} onToast={onToast} />
<div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>{config.action}</button>
<button type="button" onClick={() => navigate('friend-settings', uid)}>取消</button>
</section></div>
</div>;
} }
export default function ContactManagementView({view}) { export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onOpenScanner}) {
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
const uid = hashParams && hashParams.uid;
let content; let content;
if (view === 'address-book') content = <AddressBook />; if (view === 'address-book') content = <AddressBook myUserId={myUserId} onTopicSelected={onTopicSelected} />;
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />; else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} />; else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} onToast={setToast} onOpenScanner={onOpenScanner} />;
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />; else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification onToast={setToast} />; else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile friend={view === 'friend-profile'} onToast={setToast} />; else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />; else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'block-list') content = <BlockList onToast={setToast} />; else if (view === 'block-list') content = <BlockList onToast={setToast} />;
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" onToast={setToast} />; else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" uid={uid} tinode={tinode} onToast={setToast} />;
else if (view === 'block-confirm') content = <ConfirmSheet type="block" onToast={setToast} />; else if (view === 'block-confirm') content = <ConfirmSheet type="block" uid={uid} tinode={tinode} onToast={setToast} />;
else content = <ConfirmSheet type="clear" onToast={setToast} />; else content = <ConfirmSheet type="clear" uid={uid} tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>; return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
} }
+477 -81
View File
@@ -1,8 +1,41 @@
import React from 'react'; import React from 'react';
import HashNavigation from '../lib/navigation.js'; import { Drafty, Tinode } from 'tinode-sdk';
const navigate = view => HashNavigation.navigateTo(view); import AudioPlayer from '../widgets/audio-player.jsx';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
import {
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, getGroup, getUserProfile
} from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { topicDisplayName } from '../lib/utils.js';
// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a
// display name the same way every other list in this app does, cached since many favorites usually
// share a handful of source conversations.
const sourceNameCache = new Map();
function resolveSourceName(topic, onDone) {
if (!topic) return;
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; }
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : getUserProfile(topic).then(p => p.display_name);
call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {});
}
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
// `HashNavigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
// every internal tab/link click; nothing in this file could carry a topic/favoriteId through its own
// navigation even if a caller threaded one in from outside.
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const CONTENT_LIBRARY_VIEWS = [ export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
@@ -11,121 +44,484 @@ export const CONTENT_LIBRARY_VIEWS = [
'history-links', 'history-images', 'history-videos' 'history-links', 'history-images', 'history-videos'
]; ];
const FAVORITES = [
{id: 'text', type: 'text', text: '评审结论已经整理好了,下午三点一起确认新版流程。', source: '产品研发群', time: '今天 10:32'},
{id: 'file', type: 'file', name: '产品需求说明书.pdf', size: '2.4 MB', source: '产品研发群', time: '昨天 18:40'},
{id: 'voice', type: 'voice', duration: 12, source: '安妮', time: '星期一 09:20'},
{id: 'link', type: 'link', title: '即时通讯产品体验设计指南', summary: '从消息模型、会话体验到文件安全的完整设计方法。', url: 'design.example.com', source: '设计共创小组', time: '8月20日'},
{id: 'image', type: 'image', source: '产品研发群', time: '8月18日', image: 'img/bkg/p22.jpg'},
{id: 'video', type: 'video', source: '新版发布协作群', time: '8月16日', image: 'img/bkg/p23.jpg', duration: '00:24'}
];
const TARGETS = [
{id: 'assistant', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},
{id: 'product', name: '产品研发群', note: '18人', image: 'img/bkg/p22.jpg'},
{id: 'annie', name: '安妮', note: '在线', image: 'img/bkg/p09.jpg'},
{id: 'lizi', name: '栗子来了', note: '刚刚活跃', image: 'img/bkg/p15.jpg'}
];
const FILE_RECORDS = [
{id: 'f1', name: '产品需求说明书.pdf', size: '2.4 MB', sender: '陈晨', time: '今天 10:20', state: 'available'},
{id: 'f2', name: '交互原型最终版.fig', size: '18.6 MB', sender: '安妮', time: '昨天 18:42', state: 'available'},
{id: 'f3', name: '旧版本接口说明.docx', size: '760 KB', sender: '栗子来了', time: '8月18日', state: 'removed'}
];
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; } function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', right, onRight}) { return <header className="library-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>; } function Header({title, back = 'messages-home', backParams, right, onRight}) {
function Avatar({item}) { return <span className="library-avatar">{item.image ? <img src={item.image} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}</span>; } return <header className="library-header">
function Toast({message, onDone}) { React.useEffect(() => {if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer);}, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; } <button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
}
function Avatar({item}) { return <span className="library-avatar">{item && item.image ? <img src={item.image} alt="" /> : <Icon>{(item && item.icon) || 'person'}</Icon>}</span>; }
function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer); }, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; }
function Search({value, onChange, placeholder = '搜索'}) { return <label className="library-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; } function Search({value, onChange, placeholder = '搜索'}) { return <label className="library-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; }
function FileCard({item, onOpen}) { // -- Drafty extraction helpers: a favorite's snapshot.content (and a history item's flattened content
const removed = item.state === 'removed'; // fields) are literally the original message's own content, not a Ch.7-specific shape -- reuse the same
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}><span>PDF</span><div><strong>{item.name}</strong><small>{item.size}{item.sender ? ` · ${item.sender}` : ''}</small></div>{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}</button>; // Drafty API chat-message.jsx/context-menu.jsx already use for live messages instead of re-deriving it.
function draftyContentOf(content) {
return Drafty.isValid(content) ? content : null;
}
function firstAttachment(content) {
const drafty = draftyContentOf(content);
if (!drafty) return null;
let found = null;
Drafty.attachments(drafty, att => { if (!found) found = att; });
return found;
}
// Drafty.getDownloadUrl returns either an already-usable blob: URL (small inband attachment, .val) or
// the raw .ref relative path (out-of-band, needs an authenticated fetch) -- getFileUrl() takes a bare
// file_id and builds that path itself, so pull just the id back out of .ref rather than double it up.
function resolveAttachmentUrl(att) {
if (!att) return Promise.resolve(null);
const direct = Drafty.getDownloadUrl(att);
if (!direct) return Promise.resolve(null);
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
}
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
// path works here as everywhere else, rather than guessing at flat field names that may not exist.
function attachmentFromFlat(item) {
if (item && (item.ent || item.fmt || item.txt !== undefined)) {
return firstAttachment({txt: item.txt, fmt: item.fmt, ent: item.ent});
}
return null;
}
function plainTextOf(content, fallbackText) {
if (typeof content === 'string') return content;
const drafty = draftyContentOf(content);
if (drafty) return Drafty.toPlainText(drafty);
return fallbackText || '';
}
// file_type -> {icon, color} matching Ch.6's group-file convention (a colored square + letter/icon).
const FILE_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : '';
} }
function VoiceCard({item, playing, onPlay}) { function FileCard({name, size, mime, sender, removed, onOpen}) {
return <button type="button" className={'library-voice-card' + (playing ? ' playing' : '')} onClick={onPlay}><Icon>{playing ? 'pause_circle' : 'play_circle'}</Icon><span className="library-wave" /><strong>{item.duration}″</strong></button>; const ext = extFromName(name);
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
<span>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
<div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
</button>;
} }
function LinkCard({item, onOpen}) { // Real playback via the same widgets/audio-player.jsx used for live voice messages, rather than the
return <button type="button" className="library-link-card" onClick={onOpen}><img src="img/bkg/a03.jpg" alt="链接缩略图" /><div><strong>{item.title}</strong><p>{item.summary}</p><small><Icon>link</Icon>{item.url}</small></div></button>; // mock's plain state-toggle with no actual <audio> element -- resolves its own blob URL like Avatar
// does, so callers don't need their own per-item URL-map bookkeeping just for voice favorites.
function VoiceCard({att, head}) {
const [src, setSrc] = React.useState(null);
React.useEffect(() => { resolveAttachmentUrl(att).then(setSrc); }, [att]);
const duration = (att && att.duration) || (head && head.duration) || 0;
return <div className="library-voice-card">
{src ? <AudioPlayer short src={src} duration={duration} preview={att && att.preview} /> : <Icon>hourglass_empty</Icon>}
</div>;
} }
function FavoriteItem({item, playing, onPlay, onOpen, selecting, selected, onSelect}) { // No OG-metadata extraction exists anywhere in the backend (favoriteSnapshot/mediaMessagePayload just
// copy the raw message content -- no title/thumbnail/summary field is ever populated), matching what
// every one of the reference screenshots for both 我的收藏-链接 and 查找聊天记录-链接 actually show: a
// plain URL string, not a card. Rendering a rich card here would be inventing data that doesn't exist.
function LinkText({url, onOpen}) {
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
}
function MediaThumb({url, video, duration, onOpen}) {
return <button type="button" className="library-media-thumb" onClick={onOpen}>
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
</button>;
}
function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
const att = firstAttachment(item.snapshot && item.snapshot.content);
const source = item.source_display || (item.source && item.source.topic) || '';
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}> return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null} {selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
<div className="library-favorite-content"> <div className="library-favorite-content">
{item.type === 'text' ? <p className="library-text-content">{item.text}</p> : null} {item.type === 'text' ? <p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p> : null}
{item.type === 'file' ? <FileCard item={item} onOpen={onOpen} /> : null} {item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime} onOpen={onOpen} /> : null}
{item.type === 'voice' ? <VoiceCard item={item} playing={playing} onPlay={onPlay} /> : null} {item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} /> : null}
{item.type === 'link' ? <LinkCard item={item} onOpen={onOpen} /> : null} {item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)} onOpen={onOpen} /> : null}
{item.type === 'image' || item.type === 'video' ? <button type="button" className="library-media-thumb" onClick={onOpen}><img src={item.image} alt={item.type === 'image' ? '收藏图片' : '收藏视频'} />{item.type === 'video' ? <><Icon>play_circle</Icon><small>{item.duration}</small></> : null}</button> : null} {item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} /> : null}
<footer><span>来自 {item.source}</span><time>{item.time}</time><button type="button" aria-label="更多操作"><Icon>more_horiz</Icon></button></footer> <footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div> </div>
</article>; </article>;
} }
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'}; const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'};
const TYPE_LABELS = [['all', '全部'], ['text', '文本'], ['image', '图片'], ['video', '视频'], ['voice', '语音'], ['file', '文件'], ['link', '链接']]; const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
function Favorites({view, onToast}) { function Favorites({view, tinode, onToast}) {
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all'; const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
const selecting = view === 'favorites-select'; const selecting = view === 'favorites-select';
const [type, setType] = React.useState(selecting ? 'file' : initialType); const [type, setType] = React.useState(selecting ? 'file' : initialType);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [playing, setPlaying] = React.useState(null); const [selected, setSelected] = React.useState([]);
const [selected, setSelected] = React.useState(selecting ? ['file'] : []); // Seeded per-type -- one Favorites instance backs every favorites* tab/route (confirmed multiple
const [items, setItems] = React.useState(FAVORITES); // entry points: personal-settings' "我的收藏" row, direct-message-view.jsx's attachment-panel
const visible = items.filter(item => (type === 'all' || item.type === type) && JSON.stringify(item).toLowerCase().includes(query.toLowerCase())); // "favorite" action), so an unparameterized key would flash a stale, wrong-type list under a
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id)); // freshly-switched tab. `query` is deliberately excluded -- it always resets to '' on a tab switch,
const deleteSelected = () => {setItems(values => values.filter(item => !selected.includes(item.id))); setSelected([]); onToast('已删除所选收藏');}; // so caching a search result under the same key would show it under a plain (non-search) revisit.
return <div className="library-page"><Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'} right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} /><Search value={query} onChange={setQuery} placeholder="搜索收藏内容" /><nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''} onClick={() => {setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]);}}>{label}</button>)}</nav><main className="library-favorite-list">{visible.map(item => <FavoriteItem key={item.id} item={item} selecting={selecting} selected={selected.includes(item.id)} onSelect={() => toggle(item.id)} playing={playing === item.id} onPlay={() => setPlaying(playing === item.id ? null : item.id)} onOpen={() => item.type === 'file' ? navigate('file-download') : item.type === 'link' ? onToast('链接安全检查通过,可继续访问') : onToast('已打开收藏详情')} />)}{!visible.length ? <div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div> : null}</main>{selecting ? <nav className="library-batch-bar"><button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share')}><Icon>share</Icon><span>转发</span></button><button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button></nav> : null}</div>; const [items, setItems] = React.useState(() => readCachedData('favorites:' + type) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
const [fileUrls, setFileUrls] = React.useState({});
const [sourceNames, setSourceNames] = React.useState({});
const load = React.useCallback((append) => {
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query) writeCachedData('favorites:' + type, list);
return list;
});
setCursor(data.next_cursor || '');
setHasMore(!!data.next_cursor);
}).catch(err => onToast(err.message || '加载失败'));
}, [type, query, cursor]);
React.useEffect(() => {
setItems((!query && readCachedData('favorites:' + type)) || null);
load(false);
}, [type, query]);
// Image/video favorites need an authenticated blob URL, same as any other file_id-backed thumbnail.
React.useEffect(() => {
(items || []).forEach(item => {
if ((item.type !== 'image' && item.type !== 'video') || fileUrls[item.favorite_id]) return;
const att = firstAttachment(item.snapshot && item.snapshot.content);
resolveAttachmentUrl(att).then(url => { if (url) setFileUrls(v => ({...v, [item.favorite_id]: url})); });
});
}, [items]);
React.useEffect(() => {
(items || []).forEach(item => {
const topic = item.source && item.source.topic;
if (!topic || sourceNames[topic]) return;
resolveSourceName(topic, name => name && setSourceNames(v => ({...v, [topic]: name})));
});
}, [items]);
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
const deleteSelected = () => {
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
batchDeleteFavorites(selected).then(result => {
const okIds = new Set(((result && result.items) || []).filter(r => r.ok).map(r => r.favorite_id));
setItems(values => {
const next = values.filter(item => !okIds.has(item.favorite_id));
if (!query) writeCachedData('favorites:' + type, next);
return next;
});
setSelected(values => values.filter(id => !okIds.has(id)));
onToast(okIds.size === selected.length ? '已删除所选收藏' : `已删除 ${okIds.size}/${selected.length} 项,部分删除失败`);
}).catch(err => onToast(err.message || '删除失败'));
};
const openItem = item => {
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id}); return; }
if (item.type === 'link') {
const url = (item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text);
checkURL(url, 'favorite').then(res => {
if (res.decision === 'allow') window.open(url, '_blank', 'noopener,noreferrer');
else onToast(res.decision === 'block' ? '该链接存在风险,已被拦截' : '该链接可能存在风险,请谨慎访问');
}).catch(() => onToast('链接安全校验失败'));
return;
}
onToast('暂不支持预览此类收藏');
};
const list = items || [];
return <div className="library-page">
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'}
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
<Search value={query} onChange={setQuery} placeholder="搜索收藏内容" />
<nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''}
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
<main className="library-favorite-list">
{items === null ? <p className="library-empty-hint">加载中…</p> :
list.length ? <>{list.map(item => <FavoriteItem key={item.favorite_id} item={{...item, _thumbUrl: fileUrls[item.favorite_id],
source_display: sourceNames[item.source && item.source.topic]}}
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
onOpen={() => openItem(item)} />)}
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}</> :
<div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div>}
</main>
{selecting ? <nav className="library-batch-bar">
<button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share', {ids: selected.join(',')})}><Icon>share</Icon><span>转发</span></button>
<button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button>
</nav> : null}
</div>;
} }
function FavoriteShare({onToast}) { // Reuses the exact same real-contact-list source as Ch.4's batch-forward-dialog.jsx (the app's own
const [query, setQuery] = React.useState(''); const [selected, setSelected] = React.useState([]); const [confirming, setConfirming] = React.useState(false); const [comment, setComment] = React.useState(''); // already-subscribed chatList, filtered locally) rather than the pre-built but never-actually-wired
const visible = TARGETS.filter(item => `${item.name}${item.note}`.includes(query)); // getForwardTargets() -- that endpoint's field shape was never verified against a real caller anywhere
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id).slice(0, 9)); // in this codebase, while this pattern is proven live since Chapter 4.
return <div className="library-page"><Header title="分享收藏文件" back="favorites-select" />{!confirming ? <><Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" /><p className="library-section-title">最近会话</p><main className="library-target-list">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'library-check' + (selected.includes(item.id) ? ' checked' : '')}><Icon>{selected.includes(item.id) ? 'check' : ''}</Icon></i></button>)}</main><footer className="library-next-bar"><span>已选择 {selected.length}/9</span><button type="button" disabled={!selected.length} onClick={() => setConfirming(true)}>下一步</button></footer></> : <main className="library-share-confirm"><h2>发送给</h2><div className="library-target-summary">{TARGETS.filter(item => selected.includes(item.id)).map(item => <span key={item.id}><Avatar item={item} /><small>{item.name}</small></span>)}</div><section><FileCard item={FAVORITES[1]} /><p>收藏原件会继续保留,并在目标会话中生成一条新文件消息。</p></section><textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" /><button type="button" onClick={() => {onToast(`已分享给 ${selected.length} 个会话`); window.setTimeout(() => navigate('favorites-files'), 700);}}>发送</button></main>}</div>; function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
const ids = (hashParams && hashParams.ids ? hashParams.ids.split(',') : []).filter(Boolean);
const [query, setQuery] = React.useState('');
const [selected, setSelected] = React.useState([]);
const [confirming, setConfirming] = React.useState(false);
const [comment, setComment] = React.useState('');
const [sending, setSending] = React.useState(false);
const [preview, setPreview] = React.useState(null);
React.useEffect(() => { if (ids[0]) getFavorite(ids[0]).then(setPreview).catch(() => {}); }, []);
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id).slice(0, 9));
const send = () => {
if (sending) return;
setSending(true);
forwardFavorites(ids, selected, comment.trim()).then(result => {
const items = (result && result.items) || [];
const failedCount = items.filter(r => r.status !== 'sent').length;
onToast(failedCount ? `${selected.length - failedCount}/${selected.length} 个会话分享成功` : `已分享给 ${selected.length} 个会话`);
window.setTimeout(() => navigate('favorites-files'), 700);
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
};
const previewAtt = preview && firstAttachment(preview.snapshot && preview.snapshot.content);
const contacts = (chatList || []).filter(c => c.acs && c.acs.isJoiner() && c.acs.isWriter() &&
(!query || topicDisplayName(c.name, c.public, c.name || '').toLowerCase().includes(query.toLowerCase())));
const nameOf = uid => { const t = contacts.find(c => c.name === uid); return (t && topicDisplayName(t.name, t.public, uid)) || uid; };
return <div className="library-page">
<Header title="分享" back="favorites-select" />
{!confirming ? <>
<Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" />
<p className="library-section-title">最近聊天</p>
<main className="library-target-list">{contacts.map(item => <button type="button" key={item.name} onClick={() => toggle(item.name)}>
<Avatar item={{}} /><span><strong>{topicDisplayName(item.name, item.public, item.name)}</strong></span>
<i className={'library-check' + (selected.includes(item.name) ? ' checked' : '')}><Icon>{selected.includes(item.name) ? 'check' : ''}</Icon></i>
</button>)}</main>
<footer className="library-next-bar"><span>已选择 {selected.length}/9</span><button type="button" disabled={!selected.length} onClick={() => setConfirming(true)}>确定</button></footer>
</> : <main className="library-share-confirm">
<h2>发送给</h2>
<div className="library-target-summary">{selected.map(uid => <span key={uid}><Avatar item={{}} /><small>{nameOf(uid)}</small></span>)}</div>
{preview ? <section>{previewAtt ? <FileCard name={previewAtt.name} size={Drafty.getEntitySize(previewAtt)} onOpen={() => {}} /> :
<p className="library-text-content">{plainTextOf(preview.snapshot && preview.snapshot.content, preview.snapshot && preview.snapshot.text)}</p>}
<p>收藏原件会继续保留,并在目标会话中生成一条新消息。</p></section> : null}
<textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" />
<button type="button" disabled={sending} onClick={send}>发送</button>
</main>}
</div>;
} }
function DownloadPage({view, onToast}) { function DownloadPage({hashParams, tinode, onToast}) {
const initial = view === 'file-downloaded' ? 100 : view === 'file-downloading' ? 36 : 0; const [meta, setMeta] = React.useState(null);
const [progress, setProgress] = React.useState(initial); const [paused, setPaused] = React.useState(false); // A failed fetch previously left this stuck on "加载中…" forever -- the toast fired once and faded,
React.useEffect(() => {if (view !== 'file-downloading' || paused || progress >= 100) return undefined; const timer = window.setTimeout(() => setProgress(value => Math.min(100, value + 4)), 280); return () => window.clearTimeout(timer);}, [view, paused, progress]); // but `meta` never left null so the loading branch below never had anything else to show. Tracked
React.useEffect(() => {if (progress === 100 && view === 'file-downloading') navigate('file-downloaded');}, [progress, view]); // separately from `meta` itself since a real "still loading" state also has `meta === null`.
const state = progress === 100 ? 'done' : progress > 0 ? 'loading' : 'ready'; const [loadError, setLoadError] = React.useState(false);
return <div className="library-page"><Header title="文件详情" back="favorites-files" /><main className="library-download"><span className="library-download-icon">PDF</span><h2>产品需求说明书.pdf</h2><p>2.4 MB · PDF 文档</p><dl><div><dt>来源</dt><dd>产品研发群</dd></div><div><dt>上传者</dt><dd>陈晨</dd></div><div><dt>收藏时间</dt><dd>昨天 18:40</dd></div></dl>{state === 'loading' ? <section className="library-download-progress"><div><span>正在下载</span><strong>{progress}%</strong></div><progress value={progress} max="100" /><small>{(2.4 * progress / 100).toFixed(1)} MB / 2.4 MB</small></section> : null}{state === 'ready' ? <button type="button" className="library-primary" onClick={() => navigate('file-downloading')}><Icon>download</Icon>下载文件</button> : state === 'loading' ? <div className="library-download-actions"><button type="button" onClick={() => setPaused(!paused)}><Icon>{paused ? 'play_arrow' : 'pause'}</Icon>{paused ? '继续' : '暂停'}</button><button type="button" onClick={() => navigate('file-download')}><Icon>close</Icon>取消</button></div> : <><div className="library-complete"><Icon>check_circle</Icon><span><strong>下载完成</strong><small>文件已保存到受控目录</small></span></div><div className="library-download-actions"><button type="button" className="primary" onClick={() => onToast('已调用系统文件查看器')}><Icon>open_in_new</Icon>打开文件</button><button type="button" onClick={() => onToast('已打开系统分享面板')}><Icon>ios_share</Icon>其他应用</button></div></>}</main></div>; const [state, setState] = React.useState('ready');
const [progress, setProgress] = React.useState(0);
const downloaderRef = React.useRef(null);
const {favoriteId, topic, seq} = hashParams || {};
// Two distinct entry points share this route: Favorites (favoriteId set, Ch.7.1) and History's
// file tab (topic+seq set, Ch.7.12) -- "back" must return to whichever one actually opened this
// page, not unconditionally to favorites-files (which History never came from and has no topic
// param for anyway).
const backView = favoriteId ? 'favorites-files' : 'history-files';
const backParams = favoriteId ? undefined : {topic};
React.useEffect(() => {
const apply = content => {
const att = firstAttachment(content);
if (!att) { onToast('文件信息加载失败'); setLoadError(true); return; }
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url: Drafty.getDownloadUrl(att)});
};
if (favoriteId) {
getFavorite(favoriteId).then(fav => apply(fav.snapshot && fav.snapshot.content)).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
} else if (topic && seq) {
getMessageContext(topic, seq, 0, 0).then(data => {
const item = (data.items || []).find(m => String(m.seq) === String(seq)) || data.items[0];
apply(item && item.content);
}).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
}
}, [hashParams]);
const start = () => {
if (!meta || !tinode) return;
// A small inband attachment's "url" (from Drafty.getDownloadUrl) is already a ready-to-use
// blob: URL built from its inline base64 data -- there's nothing to fetch from the server, so
// getLargeFileHelper().download() would be the wrong tool (it requires a *relative* HTTP path and
// returns bare `undefined`, not a rejected promise, for anything else -- confirmed by triggering
// this exact case: chaining .then() off that return value threw "Cannot read properties of
// undefined"). Save the already-local blob directly instead.
if (meta.url && (meta.url.startsWith('blob:') || meta.url.startsWith('data:'))) {
const link = document.createElement('a');
link.href = meta.url; link.download = meta.name || 'file';
document.body.appendChild(link); link.click(); document.body.removeChild(link);
setState('done');
return;
}
// tinode.getLargeFileHelper().download() sends the same two wrong headers as its upload() sibling
// (X-Tinode-APIKey/X-Tinode-Auth, neither recognized by this backend) -- every real out-of-band
// download was silently failing. createFileDownloader() is a fixed-headers drop-in with the exact
// same download()/cancel() interface.
const downloader = createFileDownloader(tinode);
if (!downloader) { onToast('当前环境不支持下载'); return; }
downloaderRef.current = downloader;
setState('loading'); setProgress(0);
// download() resolves once the browser's own save dialog/native download has been triggered --
// there's no server-side download-completion record to poll (confirmed absent on the backend), so
// "downloaded" here just means the transfer finished, tracked only for this page's own lifetime.
const result = downloader.download(meta.url, meta.name, meta.mime,
loaded => setProgress(meta.size ? Math.min(100, Math.round(loaded / meta.size * 100)) : 0),
err => { setState('ready'); onToast((err && err.message) || '下载失败'); });
if (result && result.then) {
result.then(() => setState('done')).catch(() => {}); // onError above already surfaced the failure.
}
};
const cancel = () => { if (downloaderRef.current) downloaderRef.current.cancel(); setState('ready'); setProgress(0); };
if (!meta) return <div className="library-page"><Header title="文件" back={backView} backParams={backParams} /><main className="library-download">
<p className="library-empty-hint">{loadError ? '文件不存在或已被删除' : '加载中…'}</p>
</main></div>;
return <div className="library-page">
<Header title="文件" back={backView} backParams={backParams} />
<main className="library-download">
<span className="library-download-icon">{extFromName(meta.name) || '?'}</span>
<h2>{meta.name}</h2>
<p>该文件不支持在线预览</p>
{state === 'loading' ? <section className="library-download-progress">
<div><span>下载中...</span><strong>{progress}%</strong></div>
<progress value={progress} max="100" />
<small>{bytesToHumanSize(meta.size * progress / 100)} / {bytesToHumanSize(meta.size)}</small>
</section> : null}
{state === 'ready' ? <button type="button" className="library-primary" onClick={start}><Icon>download</Icon>立即下载({bytesToHumanSize(meta.size)})</button> :
state === 'loading' ? <div className="library-download-actions"><button type="button" onClick={cancel}><Icon>close</Icon>取消</button></div> :
<div className="library-download-actions"><button type="button" className="primary" onClick={() => onToast('已保存,可在浏览器下载列表中查看')}><Icon>open_in_new</Icon>用其他应用打开</button></div>}
</main>
</div>;
} }
function Highlight({text, query}) { function Highlight({text, ranges}) {
if (!query) return text; if (!ranges || !ranges.length) return text;
const index = text.indexOf(query); if (index < 0) return text; const parts = []; let last = 0;
return <>{text.slice(0, index)}<mark>{query}</mark>{text.slice(index + query.length)}</>; ranges.forEach(([start, len], i) => {
parts.push(text.slice(last, start), <mark key={i}>{text.slice(start, start + len)}</mark>);
last = start + len;
});
parts.push(text.slice(last));
return parts;
} }
function HistoryNav({active}) { function HistoryNav({active}) {
const tabs = [['history-search', '消息'], ['history-files', '文件'], ['history-links', '链接'], ['history-images', '图片'], ['history-videos', '视频']]; const tabs = [['history-search', '聊天记录'], ['history-images', '图片'], ['history-videos', '视频'], ['history-files', '文件'], ['history-links', '链接']];
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>; return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>;
} }
function History({view, onToast}) { const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
const [query, setQuery] = React.useState(view === 'history-search' ? '评审' : ''); const HISTORY_TYPE = {'history-search': 'all', 'history-files': 'file', 'history-links': 'link', 'history-images': 'image', 'history-videos': 'video'};
const messages = [{id: 1, sender: '安妮', text: '下午三点进行版本评审,请大家提前准备。', time: '今天 09:30'}, {id: 2, sender: '陈晨', text: '评审结论已经整理到产品需求文档。', time: '今天 16:42'}]; // Same bracket-label convention as message-center-view.jsx's draftyPreview() -- messageText() on the
return <div className="library-page"><Header title="查找聊天记录" back="friend-settings" /><Search value={query} onChange={setQuery} placeholder="搜索当前会话" /><HistoryNav active={view} /><main className="library-history-content">{view === 'history-search' ? <section className="library-message-results">{messages.filter(item => item.text.includes(query)).map(item => <button type="button" key={item.id} onClick={() => {onToast('已定位到原消息'); window.setTimeout(() => navigate('chat-standard'), 600);}}><Avatar item={{image: item.sender === '安妮' ? 'img/bkg/p09.jpg' : 'img/bkg/p05.jpg'}} /><span><strong>{item.sender}</strong><p><Highlight text={item.text} query={query} /></p><small>{item.time}</small></span><Icon>chevron_right</Icon></button>)}</section> : null}{view === 'history-files' ? <section className="library-record-files">{FILE_RECORDS.filter(item => item.name.includes(query)).map(item => <FileCard key={item.id} item={item} onOpen={() => navigate('file-download')} />)}</section> : null}{view === 'history-links' ? <section className="library-record-links">{FAVORITES.filter(item => item.type === 'link').map(item => <LinkCard key={item.id} item={item} onOpen={() => onToast('链接安全检查通过')} />)}</section> : null}{view === 'history-images' || view === 'history-videos' ? <MediaGrid video={view === 'history-videos'} onToast={onToast} /> : null}</main></div>; // backend has no attachment-aware summary, so a non-text search hit's own "snippet" field is often just
// the raw serialized Drafty content; a fixed label per type reads far better than that.
const TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
function History({view, hashParams, myUserId, onToast}) {
const topic = hashParams && hashParams.topic;
// Keyed by both topic and view/tab -- reached from one chat's own settings, and a user plausibly
// leaves to tweak a setting and comes back to keep browsing/searching the same conversation's
// records (or checks a different chat's history later the same session), so dropping either half
// of the key would bleed one chat's or one tab's data into another's.
const cacheKey = topic ? `history:${topic}:${view}` : null;
const [query, setQuery] = React.useState('');
const [items, setItems] = React.useState(() => (cacheKey && readCachedData(cacheKey)) || null);
const [cursor, setCursor] = React.useState('');
const [hasMore, setHasMore] = React.useState(false);
const [mediaUrls, setMediaUrls] = React.useState({});
const [senderNames, setSenderNames] = React.useState({});
const load = React.useCallback((append) => {
if (!topic) return;
const type = HISTORY_TYPE[view];
const call = view === 'history-search' ? searchTopicMessages(topic, {query, type, cursor: append ? cursor : ''}) :
getTopicMedia(topic, type, append ? cursor : '');
call.then(data => {
setItems(prev => {
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
if (!query && cacheKey) writeCachedData(cacheKey, list);
return list;
});
setCursor(data.next_cursor || ''); setHasMore(!!data.next_cursor);
}).catch(err => onToast(err.message || '加载失败'));
}, [topic, view, query, cursor]);
React.useEffect(() => {
setItems((!query && cacheKey && readCachedData(cacheKey)) || null);
load(false);
}, [topic, view, query]);
React.useEffect(() => {
if (view !== 'history-images' && view !== 'history-videos') return;
(items || []).forEach(item => {
if (mediaUrls[item.seq]) return;
resolveAttachmentUrl(attachmentFromFlat(item)).then(url => { if (url) setMediaUrls(v => ({...v, [item.seq]: url})); });
});
}, [items, view]);
// item.from (like snapshot.from on a favorite) is a bare uid with no "usr" prefix -- every other uid
// in this app (topic names, hash params, etc.) is fully-prefixed, so it has to be added back before
// this can be looked up like any other user id.
React.useEffect(() => {
(items || []).forEach(item => {
const from = item.from;
if (!from || senderNames[from] || from === myUserId || ('usr' + from) === myUserId) return;
getUserProfile(from.startsWith('usr') ? from : 'usr' + from).then(p => {
setSenderNames(v => ({...v, [from]: p.display_name}));
}).catch(() => {});
});
}, [items]);
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (HashNavigation.setUrlTopic('', topicName)
// -- an empty base hash, not the current one, per its "Clear newTopicParams after use" comment) so
// this lands the same way a normal chat-list click does, then layers the jump target on as a param;
// navigate()'s own helper only ever touches path[0]+params, never path[1] (the actual topic slot).
const jump = seq => HashNavigation.navigateTo(HashNavigation.addUrlParam(HashNavigation.setUrlTopic('', topic), 'jumpseq', seq));
const nameOf = from => {
if (!from) return '';
if (from === myUserId || ('usr' + from) === myUserId) return '我';
return senderNames[from] || from;
};
const openLink = url => {
checkURL(url, 'message').then(res => {
if (res.decision === 'allow') window.open(url, '_blank', 'noopener,noreferrer');
else onToast(res.decision === 'block' ? '该链接存在风险,已被拦截' : '该链接可能存在风险,请谨慎访问');
}).catch(() => onToast('链接安全校验失败'));
};
const list = items || [];
return <div className="library-page">
{/* Reached from either a friend's chat settings (usrXXX topic, reads hashParams.uid) or a
group's (grpXXX topic, reads hashParams.topic) -- the topic's own prefix says which settings
page to return to and which param name it expects; passing the wrong one is the same
blank-page failure as History's other entry points had before this was a switch. */}
<Header title={HISTORY_TITLES[view]}
back={topic && topic.startsWith('grp') ? 'group-settings' : 'friend-settings'}
backParams={topic && topic.startsWith('grp') ? {topic} : {uid: topic}} />
{view === 'history-search' ? <Search value={query} onChange={setQuery} placeholder="搜索当前会话" /> : null}
<HistoryNav active={view} />
<main className="library-history-content">
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
view === 'history-search' ? <section className="library-message-results">
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
<Highlight text={item.snippet || ''} ranges={item.matches} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
</button>)}
</section> :
view === 'history-files' ? <section className="library-record-files">{list.map(item => {
const att = attachmentFromFlat(item);
return <React.Fragment key={item.seq}>
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
<FileCard name={att ? att.name : item.name} size={att ? Drafty.getEntitySize(att) : item.size} mime={att && att.mime}
removed={item.availability === 'removed'} onOpen={() => navigate('file-download', {topic, seq: item.seq})} />
</React.Fragment>;
})}</section> :
view === 'history-links' ? <section className="library-record-links">{list.map(item => <React.Fragment key={item.seq}>
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)} onOpen={openLink} />
</React.Fragment>)}</section> :
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
duration={item.duration} onOpen={() => jump(item.seq)} />)}</section>}
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
</main>
</div>;
} }
function MediaGrid({video, onToast}) { export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
const images = video ? ['p23.jpg', 'p21.jpg', 'p19.jpg', 'p24.jpg'] : ['p22.jpg', 'p10.jpg', 'p17.jpg', 'p20.jpg', 'p06.jpg', 'p13.jpg'];
return <><h2 className="library-date-title">2026年8月</h2><section className="library-media-grid">{images.map((image, index) => <button type="button" key={image} onClick={() => onToast(video ? '已打开视频播放器' : '已打开大图预览')}><img src={`img/bkg/${image}`} alt={video ? '聊天视频缩略图' : '聊天图片缩略图'} />{video ? <><Icon>play_circle</Icon><small>00:{18 + index * 5}</small></> : null}</button>)}</section></>;
}
export default function ContentLibraryView({view}) {
const [toast, setToast] = React.useState(''); let content; const [toast, setToast] = React.useState(''); let content;
if (view.startsWith('favorites')) content = <Favorites view={view} onToast={setToast} />; if (view.startsWith('favorites')) content = <Favorites view={view} tinode={tinode} onToast={setToast} />;
else if (view === 'favorite-share') content = <FavoriteShare onToast={setToast} />; else if (view === 'favorite-share') content = <FavoriteShare hashParams={hashParams} myUserId={myUserId} chatList={chatList} onToast={setToast} />;
else if (view.startsWith('file-download')) content = <DownloadPage view={view} onToast={setToast} />; else if (view.startsWith('file-download')) content = <DownloadPage hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <History view={view} onToast={setToast} />; else content = <History view={view} hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>; return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
} }
File diff suppressed because it is too large Load Diff
+162 -65
View File
@@ -1,55 +1,54 @@
import React from 'react'; import React from 'react';
import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { getBootstrap, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { useCachedData } from '../lib/use-cached-data.js';
import { topicDisplayName } from '../lib/utils.js';
const navigate = view => HashNavigation.navigateTo(view); // Must go through setUrlSidePanel (not a bare navigateTo(view), which replaces the whole hash) so
// switching panels from "messages-home" doesn't silently drop path[1] -- the currently open
// conversation topic, which coexists with this panel in the split view once a chat is selected.
const navigate = view => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, view));
const DEMO_CONVERSATIONS = [ // Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
{id: 'system', title: '系统消息', summary: '您有一条新的安全通知', time: '昨天 21:03', icon: 'notifications', tone: 'orange', system: true}, // direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
{id: 'sun', title: '孙光洪', summary: '最近忙什么', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p03.jpg', online: true}, // a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently
{id: 'files', title: '文件助手', summary: '你上传了 1 份文件', time: '昨天 21:03', icon: 'folder', tone: 'green', fileAssistant: true}, // contacted" data doesn't exist as its own bootstrap field, so this derives it from the same
{id: 'zhao', title: '赵吾行', summary: '好久不见,怎样了,都挺好的', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p07.jpg'}, // `chatList` the conversation list below already uses: P2P topics only (a "people you talk to" rail
{id: 'wang', title: '王厚哲', summary: '恭喜你呀', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p12.jpg', online: true}, // isn't the place for groups), most-recently-touched first.
{id: 'sunmu', title: '孙牧森', summary: '真有意思', time: '昨天 21:03', muted: true, avatar: 'img/bkg/p18.jpg'} function frequentContacts(chatList) {
]; if (!chatList || chatList.length === 0) return [];
return chatList
const FREQUENT_CONTACTS = [ .filter(item => item.topic && Tinode.isP2PTopicName(item.topic))
{id: 'a', name: '吃火锅', avatar: 'img/bkg/p02.jpg', online: true}, .slice()
{id: 'b', name: '爱上草莓', avatar: 'img/bkg/p05.jpg', online: true}, .sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime())
{id: 'c', name: '好久鼻尖', avatar: 'img/bkg/p09.jpg'}, .slice(0, 8)
{id: 'd', name: '太想念你', avatar: 'img/bkg/p14.jpg', online: true}, .map(item => ({
{id: 'e', name: '夏纳', avatar: 'img/bkg/p21.jpg'} id: item.topic,
]; name: (item.public && item.public.fn) || '未命名',
avatar: makeImageUrl(item.public && item.public.photo),
const SYSTEM_NOTIFICATIONS = [ online: !!item.online
{ }));
id: 'device-1', date: '6月28日 10:04', title: '新设备登录成功', }
loginAt: '2026/08/24 14:17', device: 'Chrome · macOS', ip: '220.198.***.212', read: false
},
{
id: 'device-2', date: '6月30日 13:04', title: '新设备登录成功',
loginAt: '2026/08/20 09:42', device: 'iPhone 15', ip: '116.25.***.86', read: true
}
];
const QUICK_ACTIONS = [
{id: 'friends', icon: 'group', label: '朋友'},
{id: 'profile', icon: 'person_outline', label: '我的'},
{id: 'create-group', icon: 'add_comment', label: '创建群组'},
{id: 'add-friend', icon: 'person_add_alt', label: '添加好友'},
{id: 'scan', icon: 'qr_code_scanner', label: '扫一扫'}
];
// Matches docs/images/消息页面—长按弹出.png exactly (9 items, in this order -- no "编辑" despite
// bootstrap.message_policy.supported_actions listing it; the mock predates that field and the
// prototype just doesn't surface an edit action here). 'recall' is gated against supported_actions
// (server/im_http.go serveIMBootstrap); favorite/forward/translate/select are pure client affordances.
const MESSAGE_ACTIONS = [ const MESSAGE_ACTIONS = [
{id: 'quote', icon: 'format_quote', label: '引用'}, {id: 'quote', icon: 'format_quote', label: '引用'},
{id: 'copy', icon: 'content_copy', label: '复制'}, {id: 'copy', icon: 'content_copy', label: '复制'},
{id: 'favorite', icon: 'inventory_2', label: '收藏'}, {id: 'favorite', icon: 'inventory_2', label: '收藏'},
{id: 'translate', icon: 'translate', label: '翻译'}, {id: 'translate', icon: 'translate', label: '翻译'},
{id: 'forward', icon: 'reply', label: '转发'}, {id: 'forward', icon: 'reply', label: '转发'},
{id: 'recall', icon: 'undo', label: '撤回'}, {id: 'recall', icon: 'undo', label: '撤回', gated: true},
{id: 'delete', icon: 'delete_outline', label: '删除'}, {id: 'delete', icon: 'delete_outline', label: '删除', gated: true},
{id: 'select', icon: 'select_all', label: '多选'}, {id: 'select', icon: 'select_all', label: '多选'},
{id: 'receipts', icon: 'published_with_changes', label: '群回执'} // Not in bootstrap.message_policy.supported_actions at all (that list only covers 'recall' for the
// sender's own message) -- left ungated like the other pure client affordances above.
{id: 'group_recall', icon: 'restart_alt', label: '群撤回'}
]; ];
function Icon({children}) { function Icon({children}) {
@@ -66,25 +65,77 @@ function Toast({message, onDone}) {
} }
function Avatar({item, small = false}) { function Avatar({item, small = false}) {
const className = 'messages-avatar ' + (small ? 'small ' : '') + (item.tone || 'blue'); // The tone modifier is namespaced ('tone-orange', not 'orange') because base.css's original
// Tinode styles already define bare `.orange`/`.green`/`.blue` as generic `color: ... !important`
// text-color utility classes -- an unprefixed tone class collides with those and, since
// `!important` beats any selector this component could use to fight back, silently overrides the
// intended white icon-on-tint background to whatever that generic utility's text color is (this is
// exactly why the default/no-tone case rendered an invisible blue-on-blue icon: 'blue' is one of
// the collision names).
const className = 'messages-avatar ' + (small ? 'small ' : '') + 'tone-' + (item.tone || 'blue');
return <div className={className}> return <div className={className}>
{item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{item.icon || 'person'}</Icon>} {item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}
{item.online !== undefined ? <span className={item.online ? 'online' : 'offline'} /> : null} {item.online !== undefined ? <span className={item.online ? 'online' : 'offline'} /> : null}
</div>; </div>;
} }
// A chat list row needs a one-line summary even when the latest message isn't plain text. This is a
// plain-Chinese equivalent of lib/formatters.js's previewFormatter (which needs react-intl wired in,
// inconsistent with this file's all-hardcoded-Chinese mock strings) -- just enough to label the
// message by Drafty entity type for a conversation list preview.
function draftyPreview(content) {
if (typeof content === 'string' || content == null) return content || '';
if (Array.isArray(content.ent) && content.ent.length) {
const label = {IM: '[图片]', VD: '[视频]', AU: '[语音]', EX: '[文件]', TC: '[名片]', VC: '[通话]'}[content.ent[0].tp];
if (label) return label;
}
return content.txt || '[消息]';
}
function formatConversationTime(ts) {
if (!ts) return '';
const date = new Date(ts);
const now = new Date();
const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000);
if (days === 0) return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
if (days === 1) return '昨天';
if (days < 7) return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];
return date.toLocaleDateString('zh-CN', {month: 'numeric', day: 'numeric'});
}
// Entries (system messages / file assistant) come from bootstrap.entries and are always shown --
// unlike the old mock, they don't disappear once the user has real conversations (that was a bug).
function entryConversations(bootstrap) {
if (!bootstrap || !bootstrap.entries) return [];
const list = [];
const sys = bootstrap.entries.system_messages;
if (sys && sys.enabled) {
// Matches docs/images/消息.png ("最新公告"). bootstrap doesn't carry a preview of the latest
// notification, so this is a static label like the rest of that screenshot's demo content.
list.push({id: 'system', title: '系统消息', summary: '最新公告', icon: 'notifications', tone: 'orange',
system: true, unread: sys.unread_count || 0});
}
const files = bootstrap.entries.file_assistant;
if (files && files.enabled) {
list.push({id: 'files', title: '文件助手', summary: '你上传1份文件', icon: 'folder', tone: 'green',
fileAssistant: true, topic: files.topic, unread: files.unread_count || 0});
}
return list;
}
function normalizeConversations(chatList, myUserId) { function normalizeConversations(chatList, myUserId) {
if (!chatList || chatList.length === 0) return DEMO_CONVERSATIONS; if (!chatList || chatList.length === 0) return [];
return chatList.map((item, index) => { return chatList.map((item, index) => {
const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null; const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null;
let summary = item.private && item.private.comment ? item.private.comment : '暂无消息'; let summary = item.private && item.private.comment ? item.private.comment : '暂无消息';
if (latest && typeof latest.content === 'string') summary = latest.content; if (latest) summary = draftyPreview(latest.content);
if (latest && latest.from === myUserId) summary = '我:' + summary; if (latest && latest.from === myUserId) summary = '我:' + summary;
return { return {
id: item.topic || item.user || String(index), id: item.topic || item.user || String(index),
title: item.public && item.public.fn ? item.public.fn : '未命名会话', title: topicDisplayName(item.topic, item.public, '未命名会话'),
summary, summary,
time: item.touched ? new Date(item.touched).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}) : '', time: formatConversationTime(item.touched),
unread: Math.max(Number(item.unread) || 0, 0), unread: Math.max(Number(item.unread) || 0, 0),
online: !!item.online, online: !!item.online,
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()), muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
@@ -100,27 +151,41 @@ function BottomNavigation() {
</nav>; </nav>;
} }
function QuickMenu({onClose, onAction}) { function QuickMenu({onClose, onAction, permissions}) {
const actions = [
{id: 'friends', icon: 'group', label: '朋友'},
{id: 'profile', icon: 'person_outline', label: '我的'},
{id: 'create-group', icon: 'add_comment', label: '创建群组', permission: 'create_group'},
{id: 'add-friend', icon: 'person_add_alt', label: '添加好友', permission: 'user_search'},
{id: 'scan', icon: 'qr_code_scanner', label: '扫一扫', permission: 'scan_qr'}
].filter(item => !item.permission || !permissions || permissions[item.permission] !== false);
return <div className="messages-menu-backdrop" onMouseDown={onClose}> return <div className="messages-menu-backdrop" onMouseDown={onClose}>
<div className="messages-quick-menu" onMouseDown={e => e.stopPropagation()} role="menu"> <div className="messages-quick-menu" onMouseDown={e => e.stopPropagation()} role="menu">
{QUICK_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => onAction(item)}> {actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => onAction(item)}>
<Icon>{item.icon}</Icon><span>{item.label}</span> <Icon>{item.icon}</Icon><span>{item.label}</span>
</button>)} </button>)}
</div> </div>
</div>; </div>;
} }
function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) { function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOpenScanner}) {
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu'); const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu');
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
// Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a
// beat) every time this component remounted, e.g. after a trip to another sidepanel and back.
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
const offline = view === 'messages-offline' || (!!myUserId && connected === false); const offline = view === 'messages-offline' || (!!myUserId && connected === false);
const conversations = normalizeConversations(chatList, myUserId);
const conversations = entryConversations(bootstrap).concat(normalizeConversations(chatList, myUserId));
const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase())); const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
const frequent = frequentContacts(chatList);
const selectConversation = item => { const selectConversation = item => {
if (item.system || item.id === 'system') { if (item.system) {
navigate('system-messages'); navigate('system-messages');
} else if (item.fileAssistant && item.topic && onTopicSelected) {
onTopicSelected(item.topic);
} else if (chatList && chatList.length && onTopicSelected) { } else if (chatList && chatList.length && onTopicSelected) {
onTopicSelected(item.id); onTopicSelected(item.id);
} else { } else {
@@ -134,7 +199,8 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
if (item.id === 'profile') return navigate('personal-center'); if (item.id === 'profile') return navigate('personal-center');
if (item.id === 'create-group') return navigate('group-list'); if (item.id === 'create-group') return navigate('group-list');
if (item.id === 'add-friend') return navigate('add-friend'); if (item.id === 'add-friend') return navigate('add-friend');
setToast(item.id === 'scan' ? '扫码功能将在客户端能力章节提供' : `${item.label}页面将在对应章节中提供`); if (item.id === 'scan' && onOpenScanner) return onOpenScanner();
setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`);
}; };
return <div className="messages-product-page"> return <div className="messages-product-page">
@@ -144,14 +210,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
</header> </header>
<main className="messages-home-content"> <main className="messages-home-content">
<label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label> <label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label>
<section className="messages-frequent"> {frequent.length > 0 ? <section className="messages-frequent">
<h2>常联系的人</h2> <h2>常联系的人</h2>
<div className="messages-frequent-scroll"> <div className="messages-frequent-scroll">
{FREQUENT_CONTACTS.map(item => <button type="button" key={item.id} onClick={() => navigate('chat-standard')}> {frequent.map(item => <button type="button" key={item.id} onClick={() => onTopicSelected && onTopicSelected(item.id)}>
<Avatar item={item} small /><span>{item.name}</span> <Avatar item={item} small /><span>{item.name}</span>
</button>)} </button>)}
</div> </div>
</section> </section> : null}
<section className="messages-conversations" aria-label="会话列表"> <section className="messages-conversations" aria-label="会话列表">
{filtered.map(item => <button type="button" className={'messages-conversation' + (item.pinned ? ' pinned' : '')} {filtered.map(item => <button type="button" className={'messages-conversation' + (item.pinned ? ' pinned' : '')}
key={item.id} onClick={() => selectConversation(item)}> key={item.id} onClick={() => selectConversation(item)}>
@@ -165,7 +231,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
</section> </section>
</main> </main>
<BottomNavigation /> <BottomNavigation />
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} /> : null} {menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
<Toast message={toast} onDone={() => setToast('')} /> <Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
} }
@@ -174,6 +240,17 @@ function MessageActions() {
const [visible, setVisible] = React.useState(true); const [visible, setVisible] = React.useState(true);
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
const [selected, setSelected] = React.useState(''); const [selected, setSelected] = React.useState('');
const [supportedActions, setSupportedActions] = React.useState(null);
React.useEffect(() => {
getBootstrap().then(data => setSupportedActions(data.message_policy && data.message_policy.supported_actions)).catch(() => {});
}, []);
// This screen is a static illustration of the long-press menu (not attached to a real message), so
// there's no real (topic, seq) to act on -- the real wiring belongs in messages-view.jsx (chapter 4)
// where an actual message exists. Here we only let the server's declared capabilities decide which
// gated actions (recall/edit/delete) are worth showing at all.
const actions = MESSAGE_ACTIONS.filter(item => !item.gated || !supportedActions || supportedActions.includes(item.id));
const runAction = item => { const runAction = item => {
setSelected(item.id); setSelected(item.id);
@@ -195,7 +272,7 @@ function MessageActions() {
<footer className="message-demo-composer"><Icon>mic_none</Icon><span>输入消息</span><Icon>sentiment_satisfied_alt</Icon><Icon>add_circle_outline</Icon></footer> <footer className="message-demo-composer"><Icon>mic_none</Icon><span>输入消息</span><Icon>sentiment_satisfied_alt</Icon><Icon>add_circle_outline</Icon></footer>
{visible ? <div className="message-action-backdrop" onMouseDown={() => setVisible(false)}> {visible ? <div className="message-action-backdrop" onMouseDown={() => setVisible(false)}>
<div className="message-action-menu" onMouseDown={e => e.stopPropagation()} role="menu"> <div className="message-action-menu" onMouseDown={e => e.stopPropagation()} role="menu">
{MESSAGE_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}> {actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
<span>{item.label}</span><Icon>{item.icon}</Icon> <span>{item.label}</span><Icon>{item.icon}</Icon>
</button>)} </button>)}
</div> </div>
@@ -205,20 +282,40 @@ function MessageActions() {
} }
function SystemMessages({authenticated}) { function SystemMessages({authenticated}) {
const [items, setItems] = React.useState(SYSTEM_NOTIFICATIONS); const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
const markRead = id => setItems(items.map(item => item.id === id ? {...item, read: true} : item));
React.useEffect(() => {
getSystemNotifications().then(data => setItems(data.items || []))
.catch(err => setError(err.message || '加载失败'))
.finally(() => setLoading(false));
}, []);
const markRead = item => {
if (item.read_at) return;
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));
markSystemNotificationRead(item.id).catch(() => {
// Roll the optimistic update back on failure -- otherwise the "new" badge silently vanishes
// even though the server never actually recorded this notification as read.
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: null} : i));
});
};
return <div className="system-messages-page"> return <div className="system-messages-page">
<header className="messages-sub-header"><button onClick={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header> <header className="messages-sub-header"><button onClick={() => { if (!HashNavigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<main className="system-notification-list"> <main className="system-notification-list">
{items.map(item => <article key={item.id} className={item.read ? 'read' : ''} onClick={() => markRead(item.id)}> {loading ? <p className="messages-empty"><Icon>hourglass_empty</Icon>加载中…</p> : null}
<time>{item.date}</time> {error ? <p className="messages-empty"><Icon>error_outline</Icon>{error}</p> : null}
{!loading && !error && items.length === 0 ? <p className="messages-empty"><Icon>notifications_none</Icon>暂无系统消息</p> : null}
{items.map(item => <article key={item.id} className={item.read_at ? 'read' : ''} onClick={() => markRead(item)}>
<time>{new Date(item.created_at).toLocaleString('zh-CN')}</time>
<div className="system-notification-card"> <div className="system-notification-card">
<header><h2>{item.title}</h2>{!item.read ? <span>新</span> : null}</header> <header><h2>{item.title}</h2>{!item.read_at ? <span>新</span> : null}</header>
<p>亲爱的用户,您的账号于 <button type="button" onClick={() => setToast('登录时间:' + item.loginAt)}>{item.loginAt}</button> 在新设备上登录成功。</p> <p>{item.summary}</p>
<dl><div><dt>设备:</dt><dd>{item.device}</dd></div><div><dt>IP地址:</dt><dd>{item.ip}</dd></div></dl> {item.action && item.action.device ? <dl><div><dt>设备:</dt><dd>{item.action.device}</dd></div>{item.action.ip ? <div><dt>IP地址:</dt><dd>{item.action.ip}</dd></div> : null}</dl> : null}
<p>如果不是本人操作,请及时修改密码并检查登录设备。</p> <button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button>
<button type="button" className="system-security-link" onClick={() => navigate(authenticated ? 'security' : 'identity-check')}>前往安全设置 <Icon>chevron_right</Icon></button>
<footer>保持警惕<br />即时通讯安全团队</footer> <footer>保持警惕<br />即时通讯安全团队</footer>
</div> </div>
</article>)} </article>)}
+372 -28
View File
@@ -18,6 +18,7 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx'; import LogoView from './logo-view.jsx';
import MetaMessage from '../widgets/meta-message.jsx'; import MetaMessage from '../widgets/meta-message.jsx';
import PinnedMessages from '../widgets/pinned-messages.jsx'; import PinnedMessages from '../widgets/pinned-messages.jsx';
import { RiskConfirmDialog } from './security-support-view.jsx';
import SendMessage from '../widgets/send-message.jsx'; import SendMessage from '../widgets/send-message.jsx';
const TheCardPreview = React.lazy(_ => import('../widgets/the-card-preview.jsx')); const TheCardPreview = React.lazy(_ => import('../widgets/the-card-preview.jsx'));
const VideoPreview = React.lazy(_ => import('../widgets/video-preview.jsx')); const VideoPreview = React.lazy(_ => import('../widgets/video-preview.jsx'));
@@ -27,6 +28,9 @@ import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDI
MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY, QUOTED_REPLY_LENGTH, VIDEO_PREVIEW_DIM } from '../config.js'; MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY, QUOTED_REPLY_LENGTH, VIDEO_PREVIEW_DIM } from '../config.js';
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js'; import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js'; import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js'; import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -141,10 +145,17 @@ class MessagesView extends React.Component {
super(props); super(props);
this.state = MessagesView.getDerivedStateFromProps(props, {}); this.state = MessagesView.getDerivedStateFromProps(props, {});
// Not topic-specific, fetched once in componentDidMount -- unlike the rest of state this
// is intentionally not touched by getDerivedStateFromProps on topic change.
this.state.messagePolicy = null;
this.state.toast = null;
this.state.riskDialog = null;
this.componentSetup = this.componentSetup.bind(this); this.componentSetup = this.componentSetup.bind(this);
this.leave = this.leave.bind(this); this.leave = this.leave.bind(this);
this.sendMessage = this.sendMessage.bind(this); this.sendMessage = this.sendMessage.bind(this);
this.handleRiskConfirm = this.handleRiskConfirm.bind(this);
this.handleRiskCancel = this.handleRiskCancel.bind(this);
this.retrySend = this.retrySend.bind(this); this.retrySend = this.retrySend.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this); this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendVideoAttachment = this.sendVideoAttachment.bind(this); this.sendVideoAttachment = this.sendVideoAttachment.bind(this);
@@ -170,6 +181,13 @@ class MessagesView extends React.Component {
this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this); this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this); this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this); this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.loadRelationshipContext = this.loadRelationshipContext.bind(this);
this.loadGroupContext = this.loadGroupContext.bind(this);
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
this.handleBatchDelete = this.handleBatchDelete.bind(this);
this.handleBatchFavorite = this.handleBatchFavorite.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this); this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachImageOrVideo = this.handleAttachImageOrVideo.bind(this); this.handleAttachImageOrVideo = this.handleAttachImageOrVideo.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this); this.handleCancelUpload = this.handleCancelUpload.bind(this);
@@ -182,6 +200,7 @@ class MessagesView extends React.Component {
this.handleEditMessage = this.handleEditMessage.bind(this); this.handleEditMessage = this.handleEditMessage.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this); this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this); this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.jumpToMessage = this.jumpToMessage.bind(this);
this.handleUnpinMessage = this.handleUnpinMessage.bind(this); this.handleUnpinMessage = this.handleUnpinMessage.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this); this.handleCallHangup = this.handleCallHangup.bind(this);
@@ -249,12 +268,17 @@ class MessagesView extends React.Component {
this.mountDnDEvents(this.dndRef); this.mountDnDEvents(this.dndRef);
this.componentSetup({}, {}); this.componentSetup({}, {});
getBootstrap().then(data => {
this.setState({messagePolicy: data.message_policy || null});
}).catch(_ => {});
} }
componentWillUnmount() { componentWillUnmount() {
if (this.messagesScroller) { if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent); this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
} }
window.clearTimeout(this.toastTimer);
// Flush all notifications. // Flush all notifications.
this.clearNotificationQueue(); this.clearNotificationQueue();
@@ -290,6 +314,13 @@ class MessagesView extends React.Component {
this.postReadNotification(0); this.postReadNotification(0);
} }
// Ch.7: jump to a message found via chat-history search (content-library-view.jsx's History),
// passed in as a ?jumpseq= hash param rather than a normal prop since the navigation that gets us
// here also has to change the *topic*, which content-library-view.jsx has no direct way to do.
if (this.props.jumpToSeq && this.props.jumpToSeq != prevProps.jumpToSeq) {
this.jumpToMessage(parseInt(this.props.jumpToSeq, 10));
}
this.componentSetup(prevProps, prevState); this.componentSetup(prevProps, prevState);
} }
@@ -312,6 +343,9 @@ class MessagesView extends React.Component {
topic.onPres = this.handleSubsUpdated; topic.onPres = this.handleSubsUpdated;
topic.onAuxUpdated = this.handleAuxUpdate; topic.onAuxUpdated = this.handleAuxUpdate;
} }
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
} }
if (topic) { if (topic) {
@@ -368,7 +402,12 @@ class MessagesView extends React.Component {
pins: [], pins: [],
pinsLoaded: false, pinsLoaded: false,
selectedPin: 0, selectedPin: 0,
subsVersion: 0 subsVersion: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0
}; };
} else if (nextProps.topic != prevState.topic) { } else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic); const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -389,7 +428,18 @@ class MessagesView extends React.Component {
showGoToLastButton: false, showGoToLastButton: false,
contentToEdit: null, contentToEdit: null,
dragging: false, dragging: false,
selectedPin: 0 selectedPin: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0,
// MessagesView isn't remounted on topic switch (no `key={topic}` at the call site in
// tinode-web.jsx), so a pending risk-check dialog from the conversation just left would
// otherwise survive the switch -- confirming it would then send the old topic's message
// into whichever topic is now open, since doSend()'s `this.props.sendMessage` resolves the
// topic to send to at call time, not at the time the dialog was raised.
riskDialog: null
}; };
if (nextProps.forwardMessage) { if (nextProps.forwardMessage) {
@@ -435,7 +485,7 @@ class MessagesView extends React.Component {
}); });
} else if (topic.public) { } else if (topic.public) {
Object.assign(nextState, { Object.assign(nextState, {
title: topic.public.fn, title: topicDisplayName(topic.name, topic.public, ''),
avatar: makeImageUrl(topic.public.photo) avatar: makeImageUrl(topic.public.photo)
}); });
} else { } else {
@@ -517,7 +567,7 @@ class MessagesView extends React.Component {
nextState.isAdmin = false; nextState.isAdmin = false;
} }
if (!prevState.readingBlocked) { if (!prevState.readingBlocked) {
prevState.readingBlocked = true; nextState.readingBlocked = true;
} }
if (prevState.isSharer) { if (prevState.isSharer) {
nextState.isSharer = false; nextState.isSharer = false;
@@ -692,7 +742,7 @@ class MessagesView extends React.Component {
}); });
} else if (desc.public) { } else if (desc.public) {
this.setState({ this.setState({
title: desc.public.fn, title: topicDisplayName(this.props.topic, desc.public, ''),
avatar: makeImageUrl(desc.public.photo) avatar: makeImageUrl(desc.public.photo)
}); });
} else { } else {
@@ -968,9 +1018,28 @@ class MessagesView extends React.Component {
params.userName = this.state.title; params.userName = this.state.title;
} }
params.topicName = this.state.topic; params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || []; let menuItems = messageSpecificMenuItems || [];
const topic = this.props.tinode.getTopic(params.topicName); const topic = this.props.tinode.getTopic(params.topicName);
if (topic) { if (topic) {
// Edit (Ch.4): chat-message.jsx already gated 'menu_item_edit' on writer + non-attachment
// content, but has no access to the server-configured edit window (that's session-level state
// living here, not a per-message prop) -- unlike recall just below, this was never actually
// filtered by age, so an edit attempt on a message well past the window would only fail at
// send time instead of never showing the option. Same window-check shape as recall's, just
// removing the item after the fact instead of adding it, since base eligibility already ran.
const editWindow = this.state.messagePolicy ? this.state.messagePolicy.edit_window_seconds : 0;
if (menuItems.includes('menu_item_edit') && !(editWindow > 0 && params.timestamp &&
params.timestamp.getTime() > (new Date().getTime() - editWindow * 1000))) {
menuItems = menuItems.filter(item => item !== 'menu_item_edit');
}
menuItems.push('menu_item_copy', 'menu_item_favorite');
// Recall (Ch.4): own message, real seq, and still inside the server-configured window.
// Distinct from delete -- see context-menu.jsx recallOwnMessage for why both exist.
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
if (params.userFrom == this.props.myUserId && params.seq > 0 && recallWindow > 0 && params.timestamp &&
params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
menuItems.push('menu_item_recall');
}
// Self -> (1) always hard-delete with [Delete] title. // Self -> (1) always hard-delete with [Delete] title.
// P2P -> (1) hard-delete if allowed and not too old, (2) always soft-delete. // P2P -> (1) hard-delete if allowed and not too old, (2) always soft-delete.
// Group -> (1) hard-delete if allowed and not too old or owner, (2) always soft-delete. // Group -> (1) hard-delete if allowed and not too old or owner, (2) always soft-delete.
@@ -1002,6 +1071,7 @@ class MessagesView extends React.Component {
} }
} }
} }
menuItems.push('menu_item_select');
this.props.showContextMenu(params, menuItems); this.props.showContextMenu(params, menuItems);
} }
@@ -1014,19 +1084,156 @@ class MessagesView extends React.Component {
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic); this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
} }
// Fetch the real friend/block/pending relationship state for a 1:1 topic so the UI can explain
// (instead of silently disabling) why sending is restricted. Best-effort: a failed fetch just
// leaves relationshipContext null and the existing isWriter-based disable stays in effect.
loadRelationshipContext(topic) {
if (!topic || !topic.isP2PType()) {
return;
}
const topicName = topic.name;
getConversationContext(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({relationshipContext: data});
}
}).catch(_ => {});
}
// Ch.6: role badges (docs/images/群标识.png) and the announcement/join-request banners at the top of a
// group chat (docs/images/群公告状态.png, 新邀请进群申请.png) all need data no P2P topic has -- fetch it
// once per group-topic-load, same best-effort pattern as loadRelationshipContext above. Every `topic`
// field these Ch.6 endpoints return is a real grp-prefixed native Tinode name (verified against the Go
// source), so there's no Ch.5-style translation needed anywhere here.
loadGroupContext(topic) {
if (!topic || !topic.isGroupType() || topic.isChannelType()) {
return;
}
const topicName = topic.name;
getGroupMembers(topicName).then(data => {
if (this.state.topic != topicName) {
return;
}
const roles = {};
(data.items || []).forEach(m => { roles[m.user_id] = m.role; });
this.setState({groupRoles: roles, myGroupRole: roles[this.props.myUserId] || null});
// Pending join requests can only be resolved by an admin/owner -- only they can act on the banner,
// and only they pass requireIMGroupMember's IsAdmin() check on the list endpoint itself.
if (roles[this.props.myUserId] == 'owner' || roles[this.props.myUserId] == 'admin') {
getGroupJoinRequests(topicName, 'pending').then(reqData => {
if (this.state.topic == topicName) {
this.setState({pendingJoinRequests: (reqData.items || []).length});
}
}).catch(_ => {});
}
}).catch(_ => {});
getLatestGroupAnnouncement(topicName).then(data => {
if (this.state.topic == topicName) {
this.setState({groupAnnouncement: (data && data.content) ? data : null});
}
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
// selector on every hashchange, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target hash from an empty path instead of the current one.
handleShowAnnouncement(e) {
e.preventDefault();
// `from=chat` lets Announcement (group-management-view.jsx) tell this entry point apart from
// the one via group-settings -- both land on the identical path[0]/topic hash otherwise, so
// without it the "back" button can't know whether to return to group-settings or to this chat.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
}
handleShowJoinRequests(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic));
}
handleSendFriendVerification(e) {
e.preventDefault();
// Same stray-path[1] hazard as handleShowAnnouncement above (build from an empty base, not the
// current hash, since a topic is open) -- this one additionally never set `uid`, which the
// friend-verification page needs (contact-management-view.jsx's Verification reads hashParams.uid)
// to know who the verification is for; for a P2P topic that's simply the topic name itself.
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
}
showToast(text) {
this.setState({toast: text});
window.clearTimeout(this.toastTimer);
// Matches div.toast.show's own fadein(0.5s) + hold(2.5s) + fadeout(0.5s) animation length,
// so the class is removed right as the CSS fadeout finishes instead of cutting it short.
this.toastTimer = window.setTimeout(() => this.setState({toast: null}), 3500);
}
handleBatchDelete() {
const topic = this.props.tinode.getTopic(this.state.topic);
if (!topic || !this.props.selectedSeqs.length) {
return;
}
topic.delMessagesList(this.props.selectedSeqs.slice(), true).then(_ => {
this.props.onExitSelectMode();
}).catch(err => {
this.props.onError(err.message, 'err');
});
}
// Per docs/images/多选-选中对话内容-收藏成功.png the selection stays visible behind the toast --
// unlike delete, favoriting doesn't remove the messages, so there's no reason to exit select mode.
handleBatchFavorite() {
if (!this.props.selectedSeqs.length) {
return;
}
const topicName = this.state.topic;
favoriteMessages(this.props.selectedSeqs.map(seq => ({topic: topicName, seq}))).then(result => {
const failed = (result && result.failed) || [];
if (failed.length && failed.length === this.props.selectedSeqs.length) {
this.props.onError('收藏失败', 'err');
} else if (failed.length) {
this.showToast(`已收藏,${failed.length} 条失败`);
} else {
this.showToast('收藏成功');
}
}).catch(err => {
this.props.onError(err.message, 'err');
});
}
sendKeyPress(audio) { sendKeyPress(audio) {
const topic = this.props.tinode.getTopic(this.state.topic); const topic = this.props.tinode.getTopic(this.state.topic);
if (topic.isSubscribed()) { if (!topic.isSubscribed()) {
if (audio) { return;
topic.noteRecording(true);
} else {
topic.noteKeyPress();
}
} }
if (audio) {
// Doc's default for the "user is recording voice" indicator is OFF unless product opts in
// (a non-persisted {note what:"kpa"} broadcast to every other group member) -- this was firing
// unconditionally on every recording, contradicting that documented default. No product signal
// to turn it on yet, so this is a deliberate no-op rather than a removed feature: re-enable by
// restoring `topic.noteRecording(true);` here if/when that's confirmed wanted.
return;
}
topic.noteKeyPress();
} }
// sendMessage sends the message with an optional subscription to topic first. // sendMessage sends the message with an optional subscription to topic first.
sendMessage(msg, uploadCompletionPromise, uploader) { // typeHint (Ch.7): the backend's imMessageType() classifier -- which drives every type-filtered list
// in this chapter (favorites tabs, chat-history search/media tabs) -- only recognizes a flat
// content.file_id/content.url key or an explicit head['x-im-type'] hint; it never walks a real
// Drafty entity array. Since every attachment this app actually sends uses the standard ent[]
// format (Drafty.attachFile/insertImage/insertVideo), none of them carry a flat file_id, so without
// this hint every image/video/file/voice message silently classifies as plain "text" server-side.
sendMessage(msg, uploadCompletionPromise, uploader, typeHint) {
// Captured before the reply/edit branches below possibly rewrite `msg` into a Drafty object
// (Drafty.append(...) for a reply-with-quote) -- the URL risk check further down keys off
// `typeof msg == 'string'`, so without this a plain-text reply containing a link would silently
// skip the check entirely (a fresh, non-reply message with the same text does get checked).
const originalText = typeof msg === 'string' ? msg : null;
let head; let head;
if (!msg && this.props.forwardMessage) { if (!msg && this.props.forwardMessage) {
// We are forwarding a message. // We are forwarding a message.
@@ -1053,7 +1260,59 @@ class MessagesView extends React.Component {
} }
this.handleCancelReply(); this.handleCancelReply();
} }
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head); if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint};
}
const doSend = () => this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Pre-send URL risk check (POST /security/urls/check) -- the only Ch.11 "anti-fraud" mechanism
// with a real backend behind it. Only plain-text composed messages are checked (not forwards,
// uploads, or already-Drafty content) since that's the one path a URL could plausibly be typed in.
// Returns a promise resolving to `false` when the message was NOT sent (blocked/cancelled) so
// send-message.jsx can leave the composer text intact instead of optimistically clearing it --
// without this, a blocked message's text would just vanish with no failed-message trace to retry.
const urlMatch = originalText && originalText.match(/https?:\/\/\S+/);
if (urlMatch) {
return checkURL(urlMatch[0], 'message_compose').then(result => {
if (result.decision === 'allow') {
return doSend();
}
return new Promise(resolve => {
// riskDialog is a single slot -- if an earlier risk check is still awaiting the user's
// decision (two messages sent in quick succession, both flagged), overwriting it here
// would leave that first promise permanently unresolved and its message silently dropped
// with send-message.jsx never told to stop holding it as unsent. Resolving it `false` first
// means it's treated the same as the user cancelling it -- lost, but not hung forever.
if (this.state.riskDialog) this.state.riskDialog.resolve(false);
this.setState({riskDialog: {blocked: result.decision === 'block', challengeId: result.risk_challenge_id, send: doSend, resolve}});
});
}).catch(_ => doSend()); // Don't let a risk-check failure block sending altogether.
}
return doSend();
}
handleRiskConfirm() {
const dialog = this.state.riskDialog;
if (!dialog) return;
this.setState({riskDialog: null});
const finish = () => dialog.resolve(dialog.send());
if (dialog.challengeId) {
confirmRiskChallenge(dialog.challengeId, 'continue').then(finish).catch(finish);
} else {
finish();
}
}
handleRiskCancel() {
const dialog = this.state.riskDialog;
if (!dialog) return;
this.setState({riskDialog: null});
if (dialog.challengeId && !dialog.blocked) {
confirmRiskChallenge(dialog.challengeId, 'cancel').catch(_ => {});
}
dialog.resolve(false);
} }
// Retry sending a message. // Retry sending a message.
@@ -1090,7 +1349,7 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) { if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); const uploader = createChatUploader(this.props.tinode);
if (!uploader) { if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return; return;
@@ -1103,7 +1362,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise urlPromise: uploadCompletionPromise
}); });
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader); this.sendMessage(msg, uploadCompletionPromise, uploader, 'file');
} else { } else {
// Small enough to send inband. // Small enough to send inband.
fileToBase64(file) fileToBase64(file)
@@ -1112,7 +1371,7 @@ class MessagesView extends React.Component {
data: b64.bits, data: b64.bits,
filename: b64.name, filename: b64.name,
size: file.size size: file.size
}))) }), undefined, undefined, 'file'))
.catch(err => this.props.onError(err.message, 'err')); .catch(err => this.props.onError(err.message, 'err'));
} }
} }
@@ -1185,7 +1444,7 @@ class MessagesView extends React.Component {
if (blob.size > maxInbandAttachmentSize) { if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); const uploader = createChatUploader(this.props.tinode);
if (!uploader) { if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return; return;
@@ -1212,7 +1471,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption)); msg = Drafty.append(msg, Drafty.parse(caption));
} }
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader); this.sendMessage(msg, uploadCompletionPromise, uploader, 'image');
}) })
.catch(err => this.props.onError(err, 'err')); .catch(err => this.props.onError(err, 'err'));
return; return;
@@ -1234,7 +1493,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg); msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption)); msg = Drafty.append(msg, Drafty.parse(caption));
} }
this.sendMessage(msg); this.sendMessage(msg, undefined, undefined, 'image');
}); });
} }
@@ -1251,7 +1510,7 @@ class MessagesView extends React.Component {
let uploader; let uploader;
if ((videoBlob.size + previewBlob.size) > maxInbandAttachmentSize) { if ((videoBlob.size + previewBlob.size) > maxInbandAttachmentSize) {
// One or both are too large to send inband. Uploading out of band and sending as a link. // One or both are too large to send inband. Uploading out of band and sending as a link.
uploader = this.props.tinode.getLargeFileHelper(); uploader = createChatUploader(this.props.tinode);
if (!uploader) { if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return; return;
@@ -1281,7 +1540,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg); msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption)); msg = Drafty.append(msg, Drafty.parse(caption));
} }
this.sendMessage(msg); this.sendMessage(msg, undefined, undefined, 'video');
}); });
return; return;
} }
@@ -1320,7 +1579,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption)); msg = Drafty.append(msg, Drafty.parse(caption));
} }
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader); this.sendMessage(msg, uploadCompletionPromise, uploader, 'video');
}) })
.catch(err => this.props.onError(err.message, 'err')); .catch(err => this.props.onError(err.message, 'err'));
} }
@@ -1379,7 +1638,7 @@ class MessagesView extends React.Component {
const maxInbandAttachmentSize = this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024; const maxInbandAttachmentSize = this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
if (blob.size > maxInbandAttachmentSize) { if (blob.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); const uploader = createChatUploader(this.props.tinode);
if (!uploader) { if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload)); this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return; return;
@@ -1393,7 +1652,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise urlPromise: uploadCompletionPromise
}); });
// Pass data and the uploader to the TinodeWeb. // Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader); this.sendMessage(msg, uploadCompletionPromise, uploader, 'voice');
} else { } else {
// Small enough to send inband. // Small enough to send inband.
blobToBase64(blob) blobToBase64(blob)
@@ -1404,7 +1663,7 @@ class MessagesView extends React.Component {
size: blob.size, size: blob.size,
duration: duration, duration: duration,
preview: preview, preview: preview,
})) }), undefined, undefined, 'voice')
}) })
} }
}) })
@@ -1494,6 +1753,27 @@ class MessagesView extends React.Component {
} }
} }
// Ch.7 chat-history search result -> jump to the message in the real conversation. handleQuoteClick
// only scrolls to a message already present in this topic's locally-cached message list (used
// elsewhere for quoted-reply/pinned-message jumps, where that's always true); a search result can
// point at a message far outside whatever window happens to be loaded, so fetch a window around it
// first via the native SDK (not the REST search API -- handleQuoteClick reads refs off the SDK's own
// local topic cache, so the data has to land there, which only getMeta does).
jumpToMessage(seq) {
const topic = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
if (!topic || !seq || isNaN(seq)) {
return;
}
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'jumpseq'));
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
if (topic.findMessage(seq)) {
jump();
} else {
topic.getMeta(topic.startMetaQuery().withData(Math.max(1, seq - 20), seq + 21, 41).build())
.then(jump).catch(err => this.props.onError(err.message, 'err'));
}
}
handleUnpinMessage(seq) { handleUnpinMessage(seq) {
const topic = this.props.tinode.getTopic(this.state.topic); const topic = this.props.tinode.getTopic(this.state.topic);
topic.pinMessage(seq, false); topic.pinMessage(seq, false);
@@ -1568,6 +1848,10 @@ class MessagesView extends React.Component {
onSendAnswer={this.props.onCallSendAnswer} /> onSendAnswer={this.props.onCallSendAnswer} />
</Suspense>; </Suspense>;
const riskDialog = this.state.riskDialog &&
<RiskConfirmDialog blocked={this.state.riskDialog.blocked}
onConfirm={this.handleRiskConfirm} onCancel={this.handleRiskCancel} />;
let component; let component;
if (!this.state.topic) { if (!this.state.topic) {
component = <LogoView component = <LogoView
@@ -1733,6 +2017,7 @@ class MessagesView extends React.Component {
topic={this.state.topic} topic={this.state.topic}
content={msg.content} content={msg.content}
mimeType={msg.head && msg.head.mime} mimeType={msg.head && msg.head.mime}
head={msg.head}
replyToSeq={replyToSeq} replyToSeq={replyToSeq}
edited={msg.head && !msg.head.webrtc && msg.head.replace} edited={msg.head && !msg.head.webrtc && msg.head.replace}
timestamp={msg.ts} timestamp={msg.ts}
@@ -1743,11 +2028,17 @@ class MessagesView extends React.Component {
userFrom={userFrom} userFrom={userFrom}
userName={userName} userName={userName}
userAvatar={userAvatar} userAvatar={userAvatar}
senderRole={groupTopic && this.state.groupRoles ? this.state.groupRoles[thisFrom] : null}
sequence={sequence} sequence={sequence}
received={deliveryStatus} received={deliveryStatus}
uploader={msg._uploader} uploader={msg._uploader}
userIsWriter={this.state.isWriter} userIsWriter={this.state.isWriter}
userIsAdmin={this.state.isAdmin} userIsAdmin={this.state.isAdmin}
relationship={this.state.relationshipContext ? this.state.relationshipContext.relationship : null}
onSendFriendVerification={this.handleSendFriendVerification}
selectMode={this.props.selectMode}
selected={this.props.selectedSeqs && this.props.selectedSeqs.includes(msg.seq)}
onToggleSelect={this.props.onToggleMessageSelect}
pinned={this.state.pins.includes(msg.seq)} pinned={this.state.pins.includes(msg.seq)}
viewportWidth={this.props.viewportWidth} // Used by `formatter`. viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.handleShowMessageContextMenu} showContextMenu={this.handleShowMessageContextMenu}
@@ -1797,6 +2088,18 @@ class MessagesView extends React.Component {
let messagesComponent = ( let messagesComponent = (
<> <>
{this.state.groupAnnouncement ?
<a href="#" id="group-announcement-banner" onClick={this.handleShowAnnouncement}>
<i className="material-icons">campaign</i>
<span>{this.state.groupAnnouncement.content}</span>
<i className="material-icons">chevron_right</i>
</a> : null}
{this.state.pendingJoinRequests > 0 ?
<a href="#" id="group-join-request-banner" onClick={this.handleShowJoinRequests}>
<i className="material-icons">person_add_alt</i>
<span>新邀请进群申请({this.state.pendingJoinRequests})</span>
<i className="material-icons">chevron_right</i>
</a> : null}
<div id="messages-container" className={darkModeClass}> <div id="messages-container" className={darkModeClass}>
<button id="go-to-latest" className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')} <button id="go-to-latest" className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
onClick={this.goToLatestMessage}> onClick={this.goToLatestMessage}>
@@ -1817,6 +2120,9 @@ class MessagesView extends React.Component {
: null } : null }
</div> </div>
: null } : null }
<div className={'toast' + (this.state.toast ? ' show' : '')}>
<i className="material-icons">check</i>&nbsp;{this.state.toast}
</div>
</div> </div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ? {this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note"> <div id="peer-messaging-disabled-note">
@@ -1826,13 +2132,51 @@ class MessagesView extends React.Component {
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging" onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>. defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
</div> : null} </div> : null}
{this.state.unconfirmed ? {!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'pending_inbound' ?
<div id="relationship-status-note">以上是打招呼的消息</div> : null}
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'pending_outbound' ?
<div id="relationship-status-note">好友申请正在等待处理</div> : null}
{!this.state.unconfirmed && this.state.relationshipContext &&
this.state.relationshipContext.relationship == 'none' ?
<div id="relationship-status-note">你们还不是好友,发送消息前需先验证 <a href="#"
onClick={this.handleSendFriendVerification}>发送朋友验证</a></div> : null}
{this.props.selectMode ?
<div id="batch-action-bar">
<div id="batch-action-count">已选 {this.props.selectedSeqs.length} 个</div>
<div id="batch-action-buttons">
<a href="#" onClick={e => { e.preventDefault(); this.props.selectedSeqs.length && this.props.onShowBatchForward(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">forward</i>转发
</a>
<a href="#" onClick={e => { e.preventDefault(); this.handleBatchFavorite(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">star_border</i>收藏
</a>
<a href="#" onClick={e => { e.preventDefault(); this.handleBatchDelete(); }}
className={this.props.selectedSeqs.length ? '' : 'disabled'}>
<i className="material-icons">delete_outline</i>删除
</a>
</div>
<a href="#" id="batch-action-cancel" onClick={e => { e.preventDefault(); this.props.onExitSelectMode(); }}>取消</a>
</div>
: this.state.unconfirmed ?
<Invitation onAction={this.handleNewChatAcceptance} /> <Invitation onAction={this.handleNewChatAcceptance} />
: :
<SendMessage <SendMessage
tinode={this.props.tinode} tinode={this.props.tinode}
topicName={this.state.topic} topicName={this.state.topic}
noInput={!!this.props.forwardMessage} noInput={!!this.props.forwardMessage}
// relationshipContext.can_send is deliberately NOT part of this -- per the reference
// screenshots (个人对话-拉黑状态/已读状态.png etc.), every non-friend relationship
// state (pending/none/blocked) keeps the composer typable; the restriction shows up as
// a per-message failure reason (chat-message.jsx, keyed off `relationship`) after a
// real send attempt, not as a preemptively grayed-out input. can_send has no other
// reader in the codebase -- this was the only place it was gating anything, and doing
// so here made every failure-reason branch for those relationships unreachable, since
// the composer never let you attempt a send in the first place. isWriter/deleted are
// the real Tinode ACS-level restrictions and are unaffected by this.
disabled={!this.state.isWriter || this.state.deleted} disabled={!this.state.isWriter || this.state.deleted}
reply={this.state.reply} reply={this.state.reply}
initMessage={this.state.contentToEdit} initMessage={this.state.contentToEdit}
@@ -1927,7 +2271,7 @@ class MessagesView extends React.Component {
</> </>
); );
} }
component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}</div> component = <div id="topic-view" ref={this.mountDnDEvents}>{component2}{overlay}{riskDialog}</div>
} }
return component; return component;
} }
+610 -74
View File
@@ -2,31 +2,41 @@ import React from 'react';
import QRCode from 'qrcodejs'; import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
getMePrivacy, updateMePrivacy, getYouthMode, enableYouthMode, disableYouthMode, verifyIndependentPassword,
createMyQRCode, getRealnameStatus
} from '../lib/im-api.js';
const navigate = view => HashNavigation.navigateTo(view); const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const PERSONAL_SETTINGS_VIEWS = [ export const PERSONAL_SETTINGS_VIEWS = [
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
'profile-signature', 'personal-qrcode', 'android-settings', 'cache-clear-confirm', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
'app-version', 'app-update-available', 'privacy-settings', 'friend-discovery-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on', 'youth-mode-disable', 'app-version', 'privacy-settings', 'friend-discovery-settings',
'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info',
'microphone-permission-info' 'microphone-permission-info'
]; ];
const PROFILE_KEY = 'im-demo-personal-profile';
const PRIVACY_KEY = 'im-demo-privacy-settings';
const YOUTH_KEY = 'im-demo-youth-mode';
const DEFAULT_PROFILE = {nickname: '陈晨', birthDate: '1998-06-18', signature: '保持好奇,认真生活。'};
function readObject(key, fallback) {
try { return {...fallback, ...JSON.parse(window.localStorage.getItem(key))}; } catch (_) { return fallback; }
}
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; } function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'personal-center', action, actionLabel, onAction}) { function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
return <header className="personal-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{action ? <button type="button" className="text" onClick={onAction}>{actionLabel || action}</button> : <span />}</header>; return <header className="personal-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" className="text" onClick={onAction} disabled={actionDisabled}>{actionLabel || action}</button> : <span />}
</header>;
} }
function Toast({message, onDone}) { function Toast({message, onDone}) {
@@ -39,123 +49,649 @@ function Toast({message, onDone}) {
} }
function Toggle({checked, disabled, onChange}) { function Toggle({checked, disabled, onChange}) {
return <button type="button" role="switch" aria-checked={checked} disabled={disabled} className={'personal-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; return <button type="button" role="switch" aria-checked={checked} disabled={disabled}
className={'personal-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>;
} }
function MenuRow({icon, title, value, route, onClick, danger}) { function MenuRow({icon, title, value, route, onClick, danger}) {
return <button type="button" className={'personal-menu-row' + (danger ? ' danger' : '')} onClick={onClick || (() => route && navigate(route))}>{icon ? <Icon>{icon}</Icon> : null}<span><strong>{title}</strong>{value ? <small>{value}</small> : null}</span>{route || onClick ? <Icon>chevron_right</Icon> : null}</button>; return <button type="button" className={'personal-menu-row' + (danger ? ' danger' : '')}
onClick={onClick || (() => route && navigate(route))}>
{icon ? <Icon>{icon}</Icon> : null}
<span><strong>{title}</strong>{value ? <small>{value}</small> : null}</span>
{route || onClick ? <Icon>chevron_right</Icon> : null}
</button>;
}
// docs/images/我的.png 的"企业名称"字段实际上是租户名,不是组织架构的部门 -- 复用 Ch.9 workbench
// 已经验证过的读取方式(登录时 tenants/resolve 缓存进 localStorage['im-tenant'])。
function tenantName() {
try { return JSON.parse(window.localStorage.getItem('im-tenant') || '{}').name || ''; } catch (_) { return ''; }
}
function ImgFromFile({fileId, className}) {
const [url, setUrl] = React.useState(null);
React.useEffect(() => { let live = true; getFileUrl(fileId).then(u => live && setUrl(u)).catch(() => {}); return () => { live = false; }; }, [fileId]);
return url ? <img className={className} src={url} alt="" /> : <span className={className} />;
} }
function PersonalCenter() { function PersonalCenter() {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE); // Was `useState(null)` + its own effect -- remounting this (e.g. tabbing back from 设置) blanked
return <div className="personal-page personal-center-page"><Header title="我的" back="messages-home" /><main><button type="button" className="personal-identity" onClick={() => navigate('personal-profile')}><img src="img/bkg/p05.jpg" alt="" /><span><strong>{profile.nickname}</strong><small>产品研发部 · 产品经理</small><small>账号:CHEN88</small></span><Icon>chevron_right</Icon></button><section><MenuRow icon="qr_code_2" title="我的二维码" route="personal-qrcode" /></section><section><MenuRow icon="inventory_2" title="我的收藏" route="favorites" /><MenuRow icon="feedback" title="意见反馈" route="user-feedback" /><MenuRow icon="settings" title="设置" route="android-settings" /></section></main></div>; // the name/avatar back to the loading placeholder for a beat even though nothing had changed,
// same "switching pages feels like it reloads every time" complaint this cache already fixed for
// the workbench/message list/address book.
const [profile] = useCachedData('me-profile', getMeProfile, []);
return <div className="personal-page personal-center-page"><Header title="我的" back="messages-home" /><main>
<button type="button" className="personal-identity" onClick={() => navigate('personal-profile')}>
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <span className="personal-avatar-fallback"><Icon>person</Icon></span>}
<span><strong>{(profile && (profile.nickname || profile.public_account)) || '…'}</strong>
<small>企业名称:{tenantName() || '…'}</small>
<small>ID: {(profile && profile.public_account) || '…'}</small></span>
<Icon>chevron_right</Icon>
</button>
<section><MenuRow icon="qr_code" title="我的二维码" route="personal-qrcode" /></section>
<section>
<MenuRow icon="bookmark_border" title="我的收藏" route="favorites" />
<MenuRow icon="report_problem" title="关于我们" route="app-version" />
<MenuRow icon="feedback" title="意见反馈" route="user-feedback" />
<MenuRow icon="settings" title="设置" route="android-settings" />
</section>
</main></div>;
} }
function PersonalProfile({onToast}) { // docs/images/个人信息.png 两张截图字段不完全一样(一张有用户名/职位/公司,另一张有ID号/地区)--
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE); // 代码调研确认 /me/profile 真实字段只有 avatar/nickname/gender/birth_date/region/signature/email;
return <div className="personal-page"><Header title="个人信息" /><main className="personal-profile-list"><button type="button" onClick={() => onToast('头像选择器已打开')}><strong>头像</strong><img src="img/bkg/p05.jpg" alt="" /><Icon>chevron_right</Icon></button><MenuRow title="昵称" value={profile.nickname} route="profile-nickname" /><MenuRow title="出生日期" value={profile.birthDate} route="profile-birth-date" /><MenuRow title="二维码名片" value="" route="personal-qrcode" /><MenuRow title="账号" value="CHEN88" /><MenuRow title="职位" value="产品经理" /><MenuRow title="公司" value="星海科技有限公司" /><MenuRow title="个性签名" value={profile.signature || '未填写'} route="profile-signature" /></main></div>; // "职位/公司"属于第9章组织架构数据源(这里没有反查接口,不展示);"ID号"、"用户名"是原型臆造字段,
// 后端没有对应概念(用户id恒为usrXXX格式),不做。
function PersonalProfile({onToast, tinode}) {
// Shares the 'me-profile' key with PersonalCenter's already-fixed cache -- this is the hub every
// field editor below navigates back to on save, so it's revisited constantly.
const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);
const fileInputRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const load = () => getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
React.useEffect(() => { load(); }, []);
const pickAvatar = () => fileInputRef.current && fileInputRef.current.click();
const handleAvatarFile = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => updateMeProfile({avatar_file_id: fileId, version: profile.version}))
.then(load).then(() => onToast('头像已更新'))
.catch(err => onToast(err.message || '头像上传失败')).finally(() => setUploading(false));
};
const genderLabel = {male: '男', female: '女', other: '其他'}[profile && profile.gender] || '未设置';
return <div className="personal-page"><Header title="个人信息" /><main className="personal-profile-list">
<button type="button" onClick={pickAvatar} disabled={uploading}>
<strong>头像</strong>
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <Icon>person</Icon>}
<Icon>chevron_right</Icon>
</button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={handleAvatarFile} />
<MenuRow title="昵称" value={profile && (profile.nickname || '未设置')} route="profile-nickname" />
<MenuRow title="性别" value={genderLabel} route="profile-gender" />
<MenuRow title="出生日期" value={(profile && profile.birth_date) || '未设置'} route="profile-birth-date" />
<MenuRow title="二维码名片" route="personal-qrcode" />
<MenuRow title="地区" value={(profile && profile.region) || '未设置'} route="profile-region" />
<MenuRow title="个性签名" value={(profile && profile.signature) || '此人很懒什么都没留下~'} route="profile-signature" />
</main></div>;
} }
function TextEditor({field, multiline, title, maxLength, onToast}) { // Real field_rules confirmed against server/im_http.go: nickname max_length=32, signature
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE); // max_length=120 -- the reference screenshots show 120/20 respectively (swapped and wrong),
const [value, setValue] = React.useState(profile[field] || ''); // mock code already had the right numbers by luck; kept here but sourced from a constant that
// matches the server contract rather than the screenshots.
const FIELD_LIMITS = {nickname: 32, signature: 120};
function TextEditor({field, multiline, title, onToast}) {
const [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState('');
const [saving, setSaving] = React.useState(false);
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p[field] || ''); }).catch(() => {}); }, [field]);
const save = () => { const save = () => {
const normalized = value.trim(); const normalized = value.trim();
if (field === 'nickname' && !normalized) return onToast('昵称不能为空'); if (field === 'nickname' && !normalized) { onToast('昵称不能为空'); return; }
const next = {...profile, [field]: field === 'signature' ? value.trim() : normalized}; setSaving(true);
window.localStorage.setItem(PROFILE_KEY, JSON.stringify(next)); updateMeProfile({[field]: normalized, version: profile.version}).then(_ => {
onToast('资料已保存'); onToast('资料已保存');
window.setTimeout(() => navigate('personal-profile'), 650); window.setTimeout(() => navigate('personal-profile'), 500);
}).catch(err => onToast(err.message || '保存失败')).finally(() => setSaving(false));
}; };
return <div className="personal-page profile-editor-page"><Header title={title} back="personal-profile" action="完成" onAction={save} /><main><label>{multiline ? <textarea value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} /> : <input value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} />}<small>{value.length}/{maxLength}</small>{value ? <button type="button" onClick={() => setValue('')} aria-label="清空"><Icon>cancel</Icon></button> : null}</label><p>{field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}</p></main></div>;
const maxLength = FIELD_LIMITS[field];
return <div className="personal-page profile-editor-page">
<Header title={title} back="personal-profile" action="完成" onAction={save} actionDisabled={!profile || saving} />
<main>
<label>
{multiline ?
<textarea value={value} maxLength={maxLength} autoFocus placeholder="请输入……" onChange={event => setValue(event.target.value)} /> :
<input value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} />}
<small>{value.length}/{maxLength}</small>
{value ? <button type="button" onClick={() => setValue('')} aria-label="清空"><Icon>cancel</Icon></button> : null}
</label>
<p>{field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}</p>
</main>
</div>;
} }
function BirthDateEditor({onToast}) { function BirthDateEditor({onToast}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE); const [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState(profile.birthDate); const [value, setValue] = React.useState('');
const save = () => {window.localStorage.setItem(PROFILE_KEY, JSON.stringify({...profile, birthDate: value})); onToast('出生日期已保存'); window.setTimeout(() => navigate('personal-profile'), 650);}; React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.birth_date || ''); }).catch(() => {}); }, []);
return <div className="personal-page profile-editor-page"><Header title="出生日期" back="personal-profile" action="完成" onAction={save} /><main className="birth-date-editor"><Icon>cake</Icon><label><span>选择出生日期</span><input type="date" value={value} max="2026-08-25" min="1900-01-01" onChange={event => setValue(event.target.value)} /></label><p>出生日期的可见范围由隐私设置决定</p></main></div>; const save = () => {
updateMeProfile({birth_date: value, version: profile.version}).then(_ => {
onToast('出生日期已保存');
window.setTimeout(() => navigate('personal-profile'), 500);
}).catch(err => onToast(err.message || '保存失败'));
};
const today = new Date().toISOString().slice(0, 10);
return <div className="personal-page profile-editor-page">
<Header title="出生日期" back="personal-profile" action="完成" onAction={save} actionDisabled={!profile} />
<main className="birth-date-editor">
<Icon>cake</Icon>
<label><span>选择出生日期</span><input type="date" value={value} max={today} min="1900-01-01" onChange={event => setValue(event.target.value)} /></label>
<p>出生日期的可见范围由隐私设置决定</p>
</main>
</div>;
}
const GENDER_OPTIONS = [{value: 'male', label: '男'}, {value: 'female', label: '女'}, {value: 'other', label: '其他'}];
function GenderEditor({onToast}) {
const [profile, setProfile] = React.useState(null);
React.useEffect(() => { getMeProfile().then(setProfile).catch(() => {}); }, []);
const choose = value => {
updateMeProfile({gender: value, version: profile.version}).then(_ => {
onToast('性别已更新');
window.setTimeout(() => navigate('personal-profile'), 400);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="personal-page"><Header title="性别" back="personal-profile" /><main className="gender-option-list">
{GENDER_OPTIONS.map(opt => <button type="button" key={opt.value} onClick={() => choose(opt.value)} disabled={!profile}>
<strong>{opt.label}</strong>{profile && profile.gender === opt.value ? <Icon>check</Icon> : null}
</button>)}
</main></div>;
}
function RegionEditor({onToast}) {
const [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.region || ''); }).catch(() => {}); }, []);
const save = () => {
updateMeProfile({region: value.trim(), version: profile.version}).then(_ => {
onToast('地区已保存');
window.setTimeout(() => navigate('personal-profile'), 500);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="personal-page profile-editor-page">
<Header title="地区" back="personal-profile" action="完成" onAction={save} actionDisabled={!profile} />
<main><label><input value={value} maxLength={60} autoFocus placeholder="省份/城市" onChange={event => setValue(event.target.value)} /></label></main>
</div>;
} }
function PersonalQr({onToast}) { function PersonalQr({onToast}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE); // Same shared 'me-profile' key as PersonalCenter/PersonalProfile -- this only needs it for the
const [token, setToken] = React.useState(Date.now()); // avatar/name display next to the code. `qr` itself is deliberately NOT cached: createMyQRCode()
// mints/renews a signed code each call (POST semantics, not a GET), so seeding a stale payload
// risks showing an already-superseded code that fails to scan -- worse than a brief spinner.
const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);
const [qr, setQr] = React.useState(null);
const qrRef = React.useRef(null); const qrRef = React.useRef(null);
const load = () => {
createMyQRCode().then(setQr).catch(err => onToast(err.message || '二维码生成失败'));
};
React.useEffect(() => { React.useEffect(() => {
getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
load();
}, []);
React.useEffect(() => {
if (!qr || !qrRef.current) return;
qrRef.current.innerHTML = ''; qrRef.current.innerHTML = '';
new QRCode(qrRef.current, {text: `im://profile-card?token=demo-${token}&purpose=add_friend`, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M}); new QRCode(qrRef.current, {text: qr.qr_payload || qr.qr_token, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});
}, [token]); }, [qr]);
return <div className="personal-page personal-qr-page"><Header title="我的二维码" /><main><section><div className="personal-qr-profile"><img src="img/bkg/p05.jpg" alt="" /><span><strong>{profile.nickname}</strong><small>星海科技有限公司</small></span></div><div className="personal-qrcode" ref={qrRef} /><p>扫一扫上面的二维码,加我为好友</p><small>二维码凭证有效期为 24 小时</small></section><div className="personal-qr-actions"><button type="button" onClick={() => {setToken(Date.now()); onToast('二维码已刷新');}}><Icon>refresh</Icon><span>刷新</span></button><button type="button" onClick={() => onToast('二维码名片已分享')}><Icon>share</Icon><span>分享</span></button></div></main></div>;
return <div className="personal-page personal-qr-page"><Header title="我的二维码" /><main>
<section>
<div className="personal-qr-profile">
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <Icon>person</Icon>}
<span><strong>{(profile && profile.nickname) || (profile && profile.public_account)}</strong><small>{tenantName()}</small></span>
</div>
<div className="personal-qrcode" ref={qrRef} />
<p>扫一扫上面的二维码,加我为好友</p>
{qr && qr.expires_at ? <small>二维码将于 {new Date(qr.expires_at).toLocaleString()} 失效</small> : null}
</section>
<div className="personal-qr-actions">
<button type="button" onClick={() => { load(); onToast('二维码已刷新'); }}><Icon>refresh</Icon><span>刷新</span></button>
<button type="button" onClick={() => {
if (navigator.share && qr) {
navigator.share({title: '我的名片', text: qr.qr_payload || qr.qr_token}).catch(() => {});
} else {
onToast('当前环境不支持分享');
}
}}><Icon>share</Icon><span>分享</span></button>
</div>
</main></div>;
} }
function AndroidSettings({onToast}) { // docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list"><section><MenuRow icon="shield" title="账号与安全" route="security" /><MenuRow icon="notifications_none" title="消息通知" route="notification-permission" /><MenuRow icon="lock_outline" title="隐私设置" route="privacy-settings" /></section><section><MenuRow icon="photo_camera" title="相机权限说明" route="camera-permission-info" /><MenuRow icon="mic_none" title="麦克风权限说明" route="microphone-permission-info" /></section><section><MenuRow icon="delete_sweep" title="清空缓存" value="126 MB" route="cache-clear-confirm" /><MenuRow icon="child_care" title="青少年模式" value={window.localStorage.getItem(YOUTH_KEY) === 'enabled' ? '已开启' : '未开启'} route={window.localStorage.getItem(YOUTH_KEY) === 'enabled' ? 'youth-mode-on' : 'youth-mode-off'} /><MenuRow icon="system_update" title="版本更新" value="1.2.4" route="app-version" /></section><section><MenuRow icon="block" title="黑名单" route="block-list" /><MenuRow icon="wallpaper" title="聊天背景" route="wallpapers" /><MenuRow icon="info_outline" title="关于应用" route="app-version" /></section><button type="button" className="personal-logout" onClick={() => onToast('退出登录需在后端对接阶段完成会话撤销')}>退出当前登录</button></main></div>; // 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。
function SettingsList({onToast, onLogout}) {
// 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'
// has no other reader today but gets the same treatment for consistency -- both status badges here
// otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows
// "未认证" the instant this remounts, even while the still-unauthenticated-looking fetch is in
// flight) rather than a neutral loading placeholder, so caching avoids a *wrong* badge, not just a
// blank one.
const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);
const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);
React.useEffect(() => {
getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});
getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});
}, []);
const youthEnabled = youthSettings ? youthSettings.enabled : null;
const realnameStatus = realname ? realname.status : null;
const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';
const logout = () => {
if (window.confirm('确定要退出当前登录吗?')) {
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
}
};
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list">
<section>
<MenuRow icon="delete_sweep" title="清除缓存" route="cache-clear-confirm" />
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" route="clear-history-confirm" />
<MenuRow icon="password" title="修改密码" route="change-password" />
</section>
<section>
<MenuRow icon="lock_outline" title="隐私设置" route="privacy-settings" />
<MenuRow icon="block" title="黑名单" route="block-list" />
<MenuRow icon="child_care" title="青少年模式" value={youthEnabled == null ? '…' : (youthEnabled ? '已开启' : '未开启')}
route={youthEnabled ? 'youth-mode-on' : 'youth-mode-off'} />
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
</section>
<section><MenuRow icon="no_accounts" title="注销账号" route="delete-account-confirm" danger /></section>
<button type="button" className="personal-logout" onClick={logout}>退出当前登录</button>
</main></div>;
} }
function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {
return <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet" role="dialog" aria-modal="true">
<Icon>{icon}</Icon><h2>{title}</h2><p>{body}</p>
{children}
<button type="button" className={confirmDanger ? 'danger' : 'primary'} disabled={busy} onClick={onConfirm}>{busy ? '处理中…' : confirmLabel}</button>
<button type="button" onClick={onCancel} disabled={busy}>取消</button>
</section></div>;
}
// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:
// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;
// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same
// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.
function CacheClear({onToast}) { function CacheClear({onToast}) {
const [cleared, setCleared] = React.useState(false); const [busy, setBusy] = React.useState(false);
return <div className="personal-page"><AndroidSettings onToast={onToast} /><div className="personal-sheet-backdrop"><section className="personal-confirm-sheet" role="dialog" aria-modal="true"><Icon>{cleared ? 'check_circle' : 'delete_sweep'}</Icon><h2>{cleared ? '缓存已清理' : '清空缓存'}</h2><p>{cleared ? '已释放 126 MB 存储空间。' : '将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}</p>{cleared ? <button type="button" className="primary" onClick={() => navigate('android-settings')}>完成</button> : <><button type="button" className="danger" onClick={() => {setCleared(true); onToast('缓存清理完成');}}>确认清理</button><button type="button" onClick={() => navigate('android-settings')}>取消</button></>}</section></div></div>; const [done, setDone] = React.useState(false);
const [freed, setFreed] = React.useState(0);
const confirm = async () => {
setBusy(true);
try {
const before = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
const after = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;
setFreed(Math.max(0, before - after));
setDone(true);
} finally {
setBusy(false);
}
};
return <ConfirmSheet icon={done ? 'check_circle' : 'delete_sweep'} title={done ? '缓存已清理' : '清空缓存'}
body={done ? `已释放约 ${(freed / (1024 * 1024)).toFixed(1)} MB 存储空间。` : '确定清空本地的缓存数据?将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}
confirmLabel={done ? '完成' : '确定'} confirmDanger={!done} busy={busy}
onConfirm={done ? () => navigate('android-settings') : confirm} onCancel={() => navigate('android-settings')} />;
} }
function AppVersion({updatePrompt, onToast}) { function ClearHistoryConfirm({tinode, chatList, onToast}) {
const [checking, setChecking] = React.useState(false); const [busy, setBusy] = React.useState(false);
const check = () => {setChecking(true); window.setTimeout(() => {setChecking(false); navigate('app-update-available');}, 700);}; const confirm = () => {
return <div className="personal-page app-version-page"><Header title="关于与更新" back="android-settings" /><main><section className="app-brand"><span><Icon>forum</Icon></span><h2>光讯通讯</h2><p>Android 版本 1.2.4</p></section><section className="personal-menu-section"><MenuRow title="用户条款" route="app-version" /><MenuRow title="隐私协议" route="app-version" /><MenuRow title="检测新版本" value={checking ? '检测中…' : '有新版本'} onClick={check} /><MenuRow title="联系我们" value="support@im.example" /></section></main>{updatePrompt ? <div className="update-backdrop"><section className="update-dialog" role="dialog" aria-modal="true"><span><Icon>rocket_launch</Icon></span><h2>发现新版本</h2><strong>版本 1.3.0</strong><ul><li>优化消息与工作台加载速度</li><li>完善隐私设置和 Android 权限引导</li><li>修复已知问题</li></ul><button type="button" onClick={() => onToast('更新任务已交给 Android 下载管理器')}>立即更新</button><button type="button" onClick={() => navigate('app-version')}>稍后提醒</button></section></div> : null}</div>; if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); navigate('android-settings'); return; }
setBusy(true);
Promise.all(chatList.map(c => {
const topic = tinode.getTopic(c.name);
return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();
})).then(() => {
onToast('聊天记录已清空');
navigate('android-settings');
}).finally(() => setBusy(false));
};
return <ConfirmSheet icon="mark_chat_read" title="清除所有聊天记录"
body="确定清除所有聊天记录?此操作不可恢复,但不会影响好友/群聊关系和收藏内容。" busy={busy}
onConfirm={confirm} onCancel={() => navigate('android-settings')} />;
} }
// Changing password has no REST endpoint -- it's native Tinode {acc} protocol
// (Tinode.updateAccountBasic), already wired end-to-end via tinode-web.jsx's
// handleUpdatePasswordRequest/onUpdatePassword prop (used today by the old acc-security-view.jsx).
// Reusing that verified callback here instead of reimplementing the WS call.
function ChangePassword({onUpdatePassword, onToast}) {
const [oldPwd, setOldPwd] = React.useState('');
const [newPwd, setNewPwd] = React.useState('');
const [confirmPwd, setConfirmPwd] = React.useState('');
const submit = () => {
if (!oldPwd || newPwd.length < 6) { onToast('请输入原密码,新密码至少6位'); return; }
if (newPwd !== confirmPwd) { onToast('两次输入的新密码不一致'); return; }
if (!onUpdatePassword) { onToast('当前环境不支持修改密码'); return; }
onUpdatePassword(newPwd);
onToast('密码已更新');
window.setTimeout(() => navigate('android-settings'), 500);
};
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成" onAction={submit} /><main>
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
<p>Tinode 协议本身不会在服务端校验"原密码",这里的原密码输入是前端自己加的一道二次确认,防止误触。</p>
</main></div>;
}
// Account deletion is also native Tinode ({del what="user" hard=true} -> delCurrentUser(true)),
// already wired via tinode-web.jsx's handleDeleteAccount/onDeleteAccount prop. Server-side there's
// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a
// password confirmation step here is a deliberate, conservative frontend-only safeguard given how
// irreversible and consequential this action is, not something the backend requires.
function DeleteAccountConfirm({onDeleteAccount, onToast}) {
const [step, setStep] = React.useState(1);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
const proceed = () => {
if (!password) { onToast('请输入密码以确认'); return; }
if (!onDeleteAccount) { onToast('当前环境不支持注销账号'); return; }
setBusy(true);
Promise.resolve(onDeleteAccount()).catch(err => { onToast(err.message || '注销失败'); setBusy(false); });
};
if (step === 1) {
return <ConfirmSheet icon="warning" title="注销账号"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。确定要继续吗?"
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
}
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
busy={busy} onConfirm={proceed} onCancel={() => navigate('android-settings')}>
<input type="password" autoFocus value={password} onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
</ConfirmSheet>;
}
// docs/images/版本更新页面.png 其实是"关于我们"页(app图标+版本号+用户条款/隐私协议/检测新版本/联系我们),
// 品牌名图片里出现了5个不同版本(捷信密使/光讯通讯/笔墨讯使/光迅通讯...),判定为设计稿混用了多版本草稿,
// 统一用代码里已确立的 BASE_APP_NAME(纯品牌名,不带版本号后缀),不采信任何一个图片品牌名。
// "检测新版本"(及跳转应用商店的更新弹窗)是原型给移动端设计的,web版没有应用商店可跳,按要求隐藏。
function AppVersion({onToast}) {
const currentVersion = PACKAGE_VERSION;
return <div className="personal-page app-version-page"><Header title="关于我们" back="android-settings" /><main>
<section className="app-brand"><span><Icon>forum</Icon></span><h2>{BASE_APP_NAME}</h2><p>版本 {currentVersion}</p></section>
<section className="personal-menu-section">
<MenuRow title="用户条款" onClick={() => onToast('用户条款')} />
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
</section>
</main>
</div>;
}
// docs/images/隐私设置.png 只画了"添加我的方式"+"邀我入群需要验证"两项,但 UserPrivacySettings
// 结构体真实还有 profile_visibility/last_seen_visibility/read_receipts 三个字段 -- 图片大概率没拍全,
// 这三项是真实、有意义的字段,保留;"邀我入群需要验证"反过来是图片有但后端完全没有对应字段(代码调研
// 确认 UserPrivacySettings 里没有这个概念,群邀请入群逻辑本身也不检查任何隐私开关),故不做这一项,
// 记录为后端缺口而不是造一个点了没有效果的假开关。
function PrivacySettings({onToast}) { function PrivacySettings({onToast}) {
const initial = readObject(PRIVACY_KEY, {groupVerification: true, readReceipts: true, lastSeen: true}); // Shares 'me-privacy' with FriendDiscoverySettings' identical getMePrivacy() call -- a toggle page,
const [settings, setSettings] = React.useState(initial); // not a text edit form, so a background refetch self-corrects any stale toggle state harmlessly
const update = (key, value) => {const next = {...settings, [key]: value}; setSettings(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('隐私设置已保存');}; // (no risk of clobbering in-progress typing the way a free-text field would).
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list"><section><MenuRow title="添加我的方式" value="全部允许" route="friend-discovery-settings" /></section><section><div><span><strong>邀请我入群需要验证</strong><small>关闭后仍受企业安全策略保护</small></span><Toggle checked={settings.groupVerification} onChange={value => update('groupVerification', value)} /></div><div><span><strong>显示消息已读状态</strong><small>关闭后你也无法查看对方的已读状态</small></span><Toggle checked={settings.readReceipts} onChange={value => update('readReceipts', value)} /></div><div><span><strong>展示最后在线时间</strong><small>仅向联系人显示</small></span><Toggle checked={settings.lastSeen} onChange={value => update('lastSeen', value)} /></div></section><section><MenuRow title="黑名单" route="block-list" /></section></main></div>; const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);
React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);
const update = (key, value) => {
const prev = privacy;
setPrivacy({...privacy, [key]: value});
updateMePrivacy({[key]: value, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
};
const methodSummary = () => {
if (!privacy) return '';
const labels = {account: '账号', phone: '手机号', qr: '二维码', group: '群聊'};
const on = Object.entries(privacy.add_me_methods || {}).filter(([, v]) => v).map(([k]) => labels[k] || k);
if (on.length === 4) return '全部允许';
return on.length ? on.join('、') : '全部关闭';
};
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list">
<section><MenuRow title="添加我的方式" value={methodSummary()} route="friend-discovery-settings" /></section>
{privacy ? <section>
<div><span><strong>显示消息已读状态</strong><small>关闭后你也无法查看对方的已读状态</small></span>
<Toggle checked={!!privacy.read_receipts} onChange={v => update('read_receipts', v)} /></div>
<div><span><strong>展示最后在线时间</strong><small>仅向联系人显示</small></span>
{/* Backend's validVisibility only accepts everyone/friends/nobody -- 'contacts'/'hidden' were
rejected by the PATCH validator outright (every toggle attempt 400'd and silently reverted),
and even a lenient backend would enforce the opposite of what turning this "on" should do
(imCanViewLastSeen's default case treats any unrecognized value as hidden). */}
<Toggle checked={privacy.last_seen_visibility !== 'nobody'} onChange={v => update('last_seen_visibility', v ? 'friends' : 'nobody')} /></div>
<div><span><strong>允许好友查看我的资料</strong><small>关闭后仅你自己可见</small></span>
<Toggle checked={privacy.profile_visibility !== 'nobody'} onChange={v => update('profile_visibility', v ? 'friends' : 'nobody')} /></div>
</section> : null}
</main></div>;
} }
function FriendDiscoverySettings({onToast}) { function FriendDiscoverySettings({onToast}) {
const initial = readObject(PRIVACY_KEY, {account: true, phone: true, qr: true, group: true}); // Same shared 'me-privacy' key as PrivacySettings -- identical resource, so whichever of the two
const [methods, setMethods] = React.useState(initial); // screens is opened first warms the cache for the other.
const update = (key, value) => {const next = {...methods, [key]: value}; setMethods(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('添加方式已更新');}; const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);
React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);
const update = (key, value) => {
const prev = privacy;
const nextMethods = {...(privacy.add_me_methods || {}), [key]: value};
setPrivacy({...privacy, add_me_methods: nextMethods});
updateMePrivacy({add_me_methods: nextMethods, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
};
const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}]; const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}];
return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list"><p className="privacy-note">关闭某种方式后,其他人将无法通过该渠道搜索或添加你。</p><section>{rows.map(row => <div key={row.key}><span><strong>可通过{row.title}添加我</strong></span><Toggle checked={methods[row.key]} onChange={value => update(row.key, value)} /></div>)}</section></main></div>; return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list">
<p className="privacy-note">关闭某种方式后,其他人将无法通过该渠道搜索或添加你。</p>
{privacy ? <section>{rows.map(row => <div key={row.key}>
<span><strong>可通过{row.title}添加我</strong></span>
<Toggle checked={!!(privacy.add_me_methods || {})[row.key]} onChange={v => update(row.key, v)} />
</div>)}</section> : null}
</main></div>;
} }
const YOUTH_RULES = ['每日使用时长不超过 40 分钟', '限制主动或被动添加好友、加入群聊的次数', '隐藏不适合未成年人的内容与功能']; // docs/images/青少年模式未开启(安卓).png 的"每日不超过40分钟"/"加好友群聊每日各4次"是纯前端文案,
// 代码调研确认 YouthModeSettings.Restrictions 后端真实内容是三个类别码
// (night_quiet/content_filter/payment_guard),没有任何分钟数/次数字段 -- 这两个具体数字只作为说明
// 文案展示,不冒充是从后端配置读出来的。使用时长同理没有后端字段,这里做的是纯本地、按自然日重置的
// 近似计时(刷新/换设备会丢,不追求精确,比完全不显示更贴近原型但明确知道它不是权威数据)。
const YOUTH_RULES = [
`为呵护未成年人健康成长,${BASE_APP_NAME}推出青少年模式。该模式下部分功能将受限制使用`,
'每天开启后使用时长不超过 40 分钟',
'限制加好友/群聊次数(主动/被动加好友或群聊每日最多各 4 次)'
];
const YOUTH_USAGE_KEY = 'im-youth-mode-usage';
function YouthMode({state, onToast}) { function useYouthUsageSeconds(active) {
const enabled = state === 'on' || state === 'disable'; const [seconds, setSeconds] = React.useState(0);
React.useEffect(() => {
const today = new Date().toISOString().slice(0, 10);
let stored = {};
try { stored = JSON.parse(window.localStorage.getItem(YOUTH_USAGE_KEY) || '{}'); } catch (_) { /* ignore */ }
if (stored.date !== today) stored = {date: today, seconds: 0};
setSeconds(stored.seconds || 0);
if (!active) return undefined;
const timer = window.setInterval(() => {
setSeconds(s => {
const next = s + 1;
window.localStorage.setItem(YOUTH_USAGE_KEY, JSON.stringify({date: today, seconds: next}));
return next;
});
}, 1000);
return () => window.clearInterval(timer);
}, [active]);
return seconds;
}
function formatHMS(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const pad = n => String(n).padStart(2, '0');
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
function YouthMode({onToast}) {
// Shares 'youth-mode' with SettingsList's badge fetch (see comment there) and gets patched directly
// by YouthModeVerify right after a successful enable/disable, so navigating back here shows the new
// state immediately instead of the stale cached one for a moment before self-correcting.
const [settings, setSettings] = React.useState(() => readCachedData('youth-mode') || null);
const usage = useYouthUsageSeconds(settings && settings.enabled);
React.useEffect(() => {
setSettings(readCachedData('youth-mode') || null);
getYouthMode().then(d => { writeCachedData('youth-mode', d); setSettings(d); }).catch(err => onToast(err.message || '加载失败'));
}, []);
const startEnable = () => {
if (!settings) return;
if (!settings.password_set) {
onToast('请先设置独立密码');
window.setTimeout(() => navigate('identity-check'), 400);
return;
}
navigate('youth-mode-verify', {ym: 'enable'});
};
if (!settings) return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /></div>;
return <div className="personal-page youth-mode-page">
<Header title="青少年模式" back="android-settings" />
<main>
<Icon>{settings.enabled ? 'verified_user' : 'child_care'}</Icon>
<h2>{settings.enabled ? '青少年模式已开启' : '青少年模式未开启'}</h2>
<ul>{YOUTH_RULES.map(rule => <li key={rule}>{rule}</li>)}</ul>
{settings.enabled ? <p className="youth-usage">今日已使用:{formatHMS(usage)}</p> : null}
</main>
<footer>
<button type="button" className={settings.enabled ? 'outline' : ''}
onClick={() => settings.enabled ? navigate('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
{settings.enabled ? '关闭青少年模式' : '开启青少年模式'}
</button>
<small>可在设置中查看青少年模式状态</small>
</footer>
</div>;
}
// docs/images/青少年模式提示(安卓).png 实际内容是"今日已达40分钟拦截层"(文件名误导),其中的
// "输入密码继续使用"选项代码调研确认后端没有任何"临时解锁但不关闭"的操作可对应 -- serveIMYouthModeEnable
// /Disable 只有开/关两种终态,没有"本次会话继续"的中间态。不做这个假按钮,只保留真实可行的"关闭"路径。
//
// 开关流程统一:已设置密码时,输入密码 -> POST .../independent-password/verify 换 action_token
// (action 按 enable/disable 传 enable_youth_mode/close_youth_mode)-> 调 enable/disable。
// 未设置密码时开启前必须先走 identity-check(短信验证码)设置密码,这一步不消费于开启本身。
function YouthModeVerify({hashParams, onToast}) {
const mode = (hashParams && hashParams.ym) || 'disable';
const [settings, setSettings] = React.useState(null);
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const enable = () => {window.localStorage.setItem(YOUTH_KEY, 'enabled'); onToast('青少年模式已开启'); window.setTimeout(() => navigate('youth-mode-on'), 650);}; const [error, setError] = React.useState('');
const disable = () => {if (password.length < 6) return onToast('请输入 6 位独立密码'); window.localStorage.removeItem(YOUTH_KEY); setPassword(''); onToast('青少年模式已关闭'); window.setTimeout(() => navigate('youth-mode-off'), 650);}; const [busy, setBusy] = React.useState(false);
return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /><main><Icon>{enabled ? 'verified_user' : 'child_care'}</Icon><h2>{enabled ? '青少年模式已开启' : '青少年模式未开启'}</h2><ul>{YOUTH_RULES.map(rule => <li key={rule}>{rule}</li>)}</ul>{enabled ? <p className="youth-usage">今日已使用:00:24:05</p> : null}</main><footer><button type="button" className={enabled ? 'outline' : ''} onClick={() => navigate(enabled ? 'youth-mode-disable' : 'youth-mode-confirm')}>{enabled ? '关闭青少年模式' : '开启青少年模式'}</button><small>可在设置中查看青少年模式状态</small></footer>{state === 'confirm' ? <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet youth-confirm" role="dialog" aria-modal="true"><Icon>family_restroom</Icon><h2>开启青少年模式</h2><p>开启后将按照当前企业的青少年保护策略限制使用时长、社交次数和部分内容。独立密码用于关闭模式。</p><button type="button" className="primary" onClick={enable}>确认并开启</button><button type="button" onClick={() => navigate('youth-mode-off')}>取消</button></section></div> : null}{state === 'disable' ? <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet youth-confirm" role="dialog" aria-modal="true"><Icon>password</Icon><h2>关闭青少年模式</h2><p>请输入开启青少年模式时设置的 6 位独立密码。</p><input type="password" inputMode="numeric" maxLength="6" value={password} autoFocus onChange={event => setPassword(event.target.value.replace(/\D/g, ''))} placeholder="独立密码" /><button type="button" className="primary" onClick={disable}>验证并关闭</button><button type="button" onClick={() => navigate('youth-mode-on')}>取消</button></section></div> : null}</div>;
React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []);
const submit = () => {
if (password.length !== 4) { setError('请输入4位独立密码'); return; }
setBusy(true);
setError('');
// Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode'
// match (ConsumeActionToken does an exact WHERE action=? lookup) -- minting it tagged
// 'set_independent_password' meant the lookup could never find it, so every enable attempt
// 403'd with ACTION_TOKEN_INVALID regardless of a correct password.
const action = mode === 'enable' ? 'enable_youth_mode' : 'close_youth_mode';
verifyIndependentPassword(password, 'youth_mode', action).then(({action_token}) => {
const call = mode === 'enable' ?
enableYouthMode(settings.policy_version, action_token) :
disableYouthMode(action_token);
return call;
}).then(_ => {
// Patch the shared cache with the now-known new state before navigating back to YouthMode/
// SettingsList -- without this they'd briefly show the pre-toggle cached value again and then
// flip, which is a more confusing flash than the plain loading state they had before caching.
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => setError(err.message || '验证失败')).finally(() => setBusy(false));
};
return <div className="personal-page youth-mode-page">
<Header title={mode === 'enable' ? '开启青少年模式' : '关闭青少年模式'} back={mode === 'enable' ? 'youth-mode-off' : 'youth-mode-on'} />
<main className="youth-password-gate">
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
<input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus
value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" />
{error ? <div className="auth-field-error">{error}</div> : null}
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy} onClick={submit}>{busy ? '验证中…' : '确定'}</button>
</main>
</div>;
} }
// 10.18-10.21:纯静态权限说明卡片,浏览器原生权限API(Notification.requestPermission()/
// getUserMedia())本身没有"用途说明"这个中间步骤,所以这一层完全是前端自己在真正触发系统权限
// 弹窗前插入的说明UI,不需要任何后端接口 -- 与代码调研确认的"清缓存/清聊天记录"是同一类判断。
const PERMISSIONS = { const PERMISSIONS = {
notification: {icon: 'notifications_active', title: '获取通知权限', text: '用于及时送达新消息、会议提醒和安全通知。', action: '开启通知', route: 'permission-settings-reminder'}, notification: {icon: 'notifications_active', title: '获取推送权限', text: '向您及时送达重要通知、活动提醒、个性化内容更新等信息。', action: '开启通知', route: 'permission-settings-reminder'},
reminder: {icon: 'settings', title: '消息推送暂未开启', text: '请前往 Android 系统设置,在通知管理中允许光讯通讯发送通知。', action: '前往设置', route: 'android-settings'}, reminder: {icon: 'settings', title: '消息推送暂未开启', text: `前往【设置 - 通知管理】一键开启吧!`, action: '好的', secondaryAction: '暂不开启', route: 'android-settings'},
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '用于拍照、扫码和视频通话。拒绝后仍可从相册选择图片。', action: '继续授权', route: 'messages-home'}, camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '调用相机拍摄/扫码,为您提供拍照、扫码识别等服务。', action: '继续授权', route: 'messages-home'},
microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'} microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'}
}; };
function PermissionGuide({type, onToast}) { function PermissionGuide({type, onToast}) {
const config = PERMISSIONS[type]; const config = PERMISSIONS[type];
const proceed = () => {onToast(type === 'reminder' ? '已打开 Android 应用设置' : '等待 Android 系统授权结果'); window.setTimeout(() => navigate(config.route), 650);}; const proceed = () => {
return <div className="personal-page permission-guide-page"><div className="permission-preview"><header><h1>消息</h1><Icon>menu</Icon></header><label><Icon>search</Icon><span>搜索</span></label>{['系统消息', '产品研发群', '文件助手', '安妮'].map((name, index) => <div key={name}><span className={'permission-avatar tone-' + index}><Icon>{index === 0 ? 'notifications' : index === 2 ? 'folder' : 'person'}</Icon></span><span><strong>{name}</strong><small>{index ? '最近有一条新消息' : '账号安全提醒'}</small></span></div>)}</div><div className="permission-overlay"><section role="dialog" aria-modal="true"><span><Icon>{config.icon}</Icon></span><h2>{config.title}</h2><p>{config.text}</p><button type="button" onClick={proceed}>{config.action}</button><button type="button" onClick={() => navigate('android-settings')}>暂不开启</button></section></div></div>; if (type === 'notification' && 'Notification' in window) {
Notification.requestPermission().then(() => navigate(config.route));
return;
}
onToast(type === 'reminder' ? '请前往系统设置开启通知' : '等待系统授权结果');
window.setTimeout(() => navigate(config.route), 500);
};
return <div className="personal-page permission-guide-page">
<div className="permission-preview"><header><h1>消息</h1><Icon>menu</Icon></header></div>
<div className="permission-overlay"><section role="dialog" aria-modal="true">
<span><Icon>{config.icon}</Icon></span>
<h2>{config.title}</h2>
<p>{config.text}</p>
<button type="button" onClick={proceed}>{config.action}</button>
{config.secondaryAction ? <button type="button" onClick={() => navigate('messages-home')}>{config.secondaryAction}</button> : null}
</section></div>
</div>;
} }
export default function PersonalSettingsView({view}) { export default function PersonalSettingsView({view, hashParams, myUserId, tinode, chatList, onUpdatePassword, onDeleteAccount, onLogout}) {
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
let content; let content;
if (view === 'personal-center') content = <PersonalCenter />; if (view === 'personal-center') content = <PersonalCenter />;
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} />; else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} tinode={tinode} />;
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" maxLength={32} onToast={setToast} />; else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" onToast={setToast} />;
else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" maxLength={120} multiline onToast={setToast} />; else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" multiline onToast={setToast} />;
else if (view === 'profile-birth-date') content = <BirthDateEditor onToast={setToast} />; else if (view === 'profile-birth-date') content = <BirthDateEditor onToast={setToast} />;
else if (view === 'profile-gender') content = <GenderEditor onToast={setToast} />;
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />; else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
else if (view === 'android-settings') content = <AndroidSettings onToast={setToast} />; else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} />;
else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />; else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />;
else if (view === 'app-version' || view === 'app-update-available') content = <AppVersion updatePrompt={view === 'app-update-available'} onToast={setToast} />; else if (view === 'clear-history-confirm') content = <ClearHistoryConfirm onToast={setToast} tinode={tinode} chatList={chatList} />;
else if (view === 'change-password') content = <ChangePassword onToast={setToast} onUpdatePassword={onUpdatePassword} />;
else if (view === 'delete-account-confirm') content = <DeleteAccountConfirm onToast={setToast} onDeleteAccount={onDeleteAccount} />;
else if (view === 'app-version') content = <AppVersion onToast={setToast} />;
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />; else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />; else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
else if (view.startsWith('youth-mode')) content = <YouthMode state={view.replace('youth-mode-', '')} onToast={setToast} />; else if (view === 'youth-mode-off' || view === 'youth-mode-on') content = <YouthMode onToast={setToast} />;
else if (view === 'youth-mode-verify') content = <YouthModeVerify hashParams={hashParams} onToast={setToast} />;
else content = <PermissionGuide type={view === 'notification-permission' ? 'notification' : view === 'permission-settings-reminder' ? 'reminder' : view === 'camera-permission-info' ? 'camera' : 'microphone'} onToast={setToast} />; else content = <PermissionGuide type={view === 'notification-permission' ? 'notification' : view === 'permission-settings-reminder' ? 'reminder' : view === 'camera-permission-info' ? 'camera' : 'microphone'} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>; return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
} }
+205 -45
View File
@@ -1,29 +1,26 @@
import React from 'react'; import React from 'react';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
const navigate = view => HashNavigation.navigateTo(view); const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const SECURITY_SUPPORT_VIEWS = [ export const SECURITY_SUPPORT_VIEWS = [
'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons', 'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons',
'abuse-report-details', 'user-feedback' 'abuse-report-details', 'user-feedback'
]; ];
const REPORT_REASON_KEY = 'im-demo-abuse-reason';
const REPORT_REASONS = [
{code: 'fraud', label: '诈骗或骗取钱财', description: '诱导转账、刷单、投资或提供验证码', icon: 'currency_yuan', required: true},
{code: 'harassment', label: '骚扰或恶意联系', description: '频繁打扰、辱骂、威胁或跟踪', icon: 'record_voice_over'},
{code: 'sexual', label: '色情或低俗内容', description: '发送色情图片、文字或相关服务信息', icon: 'visibility_off'},
{code: 'illegal', label: '违法违禁内容', description: '赌博、毒品、暴力或其他违法信息', icon: 'gavel'},
{code: 'misinformation', label: '谣言或虚假信息', description: '发布误导性、冒充身份或不实信息', icon: 'campaign'},
{code: 'infringement', label: '侵权行为', description: '侵犯隐私、肖像、著作权或商标权', icon: 'copyright'},
{code: 'other', label: '其他违规行为', description: '不属于以上类型的问题', icon: 'more_horiz', required: true}
];
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; } function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back}) { function Header({title, back}) {
return <header className="support-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>; return <header className="support-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
} }
function Toast({message, onDone}) { function Toast({message, onDone}) {
@@ -35,53 +32,216 @@ function Toast({message, onDone}) {
return message ? <div className="support-toast" role="status">{message}</div> : null; return message ? <div className="support-toast" role="status">{message}</div> : null;
} }
function ConversationPreview() { // Backend has no "content risk scan" or "post-send fraud alert" mechanism (confirmed against
return <div className="risk-conversation-preview"><header><button type="button"><Icon>arrow_back_ios_new</Icon></button><span><strong>安妮</strong><small>在线</small></span><Icon>more_vert</Icon></header><main><p>今天 14:28</p><div className="peer">这个内部项目收益很高,名额有限。</div><div className="peer">先转一笔保证金,我马上帮你办理。</div><div className="mine">好的,我现在转账。</div></main><footer><span>输入消息</span><Icon>add_circle_outline</Icon></footer></div>; // im_be/server/im_http.go and session.go -- grepping for 诈骗/反诈/110/fraud in the whole backend
// only turns up abuse-report reason labels, no scanning logic and no system-notification event type
// for it). The two reference images (反诈弹窗(安卓).png / 反诈弹窗(二次提示安卓).png) show generic
// anti-fraud education content that isn't tied to any specific message or backend-driven trigger --
// there's no field anywhere (bootstrap, profile, settings) indicating "has this been shown before".
// So this is implemented as a standalone, always-reachable safety-info page (not something that
// pops up automatically), reusing the two designs' content/copy verbatim for their real interactive
// counterpart: the pre-send URL risk check below (RiskConfirmDialog), which DOES have a real backend
// (POST /security/urls/check + POST /security/risk-challenges/{id}/confirm).
function FraudAwareness({secondary}) {
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span>
{secondary ? <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。</p>
<aside><Icon>phone_in_talk</Icon><span>如遇诈骗请立即拨打 110 报警。</span></aside>
</> : <>
<p>为保障您的账号及资金安全,请您注意:</p>
<ul>
<li><Icon>receipt_long</Icon>不参与刷单返利活动</li>
<li><Icon>currency_yuan</Icon>不轻信高收益投资、理财信息</li>
<li><Icon>person_off</Icon>不出租、出借、出售账号及实名信息</li>
<li><Icon>lock</Icon>不向任何人泄露验证码、密码等敏感信息</li>
</ul>
<p className="risk-emphasis">请提高安全防范意识,谨防电信网络诈骗。</p>
</>}
<button type="button" className="primary" onClick={() => navigate(secondary ? 'fraud-risk-warning' : 'personal-center')}>{secondary ? '我已知晓' : '我已阅读并知晓'}</button>
</main></div>;
} }
function RiskWarning({secondary, onToast}) { // Exported for messages-view.jsx to render inline when composing a message that contains a URL --
const continueAction = () => { // this is the one piece of Ch.11 with a genuine backend mechanism behind it (checkURL/confirmRiskChallenge,
if (!secondary) return navigate('fraud-risk-confirmation'); // verified against im_http.go:1184-1285). `level`/`blocked` come straight from the checkURL response.
onToast('已记录风险确认,将返回原操作'); export function RiskConfirmDialog({level, blocked, onConfirm, onCancel}) {
window.setTimeout(() => navigate('chat-standard'), 750); return <div className="risk-overlay"><section className="risk-dialog secondary" role="alertdialog" aria-modal="true">
}; <span className="risk-symbol"><Icon>{blocked ? 'block' : 'warning_amber'}</Icon></span>
return <div className="support-page risk-warning-page"><ConversationPreview /><div className="risk-overlay"><section className={'risk-dialog' + (secondary ? ' secondary' : '')} role="alertdialog" aria-modal="true"><span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span><h2>安全提醒</h2>{secondary ? <><p className="risk-emphasis">当前操作可能涉及诈骗风险,请再次确认。</p><p>请勿轻信陌生人的刷单返利、投资理财、网络贷款等信息。验证码、密码和转账信息不要提供给任何人。</p><aside><Icon>phone_in_talk</Icon><span>如遇诈骗,请立即停止操作并拨打 110 报警。</span></aside></> : <><p>为保障你的账号和资金安全,请注意:</p><ul><li><Icon>money_off</Icon>不参与刷单返利或高收益投资</li><li><Icon>badge</Icon>不出租、出借或出售账号和实名信息</li><li><Icon>password</Icon>不向任何人泄露验证码和密码</li><li><Icon>link_off</Icon>不打开来源不明的链接或安装包</li></ul></>}<button type="button" className={secondary ? 'danger' : 'primary'} onClick={continueAction}>{secondary ? '我已确认风险,仍要继续' : '我已阅读,继续'}</button><button type="button" onClick={() => {onToast('操作已取消'); window.setTimeout(() => navigate('chat-standard'), 650);}}>{secondary ? '停止操作' : '取消'}</button></section></div></div>; <h2>安全提醒</h2>
{blocked ? <p className="risk-emphasis">检测到该内容存在较高安全风险,暂无法发送。</p> : <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。如遇诈骗请立即拨打 110 报警。</p>
</>}
{blocked
? <button type="button" onClick={onCancel}>我已知晓</button>
: <><button type="button" className="danger" onClick={onConfirm}>我已知晓,继续发送</button><button type="button" onClick={onCancel}>取消发送</button></>}
</section></div>;
} }
function ReportReasons() { // target_type is one of user/group/message only (verified against server/store/store.go's
const select = reason => {window.sessionStorage.setItem(REPORT_REASON_KEY, JSON.stringify(reason)); navigate('abuse-report-details');}; // AbuseReasons validation and im_http.go's normalizeIMAbuseTarget switch -- there is no
return <div className="support-page"><Header title="举报" back="friend-settings" /><main className="report-reasons"><section className="report-target"><img src="img/bkg/p09.jpg" alt="" /><span><strong>举报安妮</strong><small>举报人身份不会向对方公开</small></span><Icon>verified_user</Icon></section><h2>请选择举报原因</h2><section className="report-reason-list">{REPORT_REASONS.map(reason => <button type="button" key={reason.code} onClick={() => select(reason)}><span><Icon>{reason.icon}</Icon></span><span><strong>{reason.label}</strong><small>{reason.description}</small></span><Icon>chevron_right</Icon></button>)}</section></main></div>; // "group_member" variant server-side; reporting a group member uses target_type=user and simply
// loses the "which group" context, which is a real backend limitation, not something fixable here).
function ReportReasons({hashParams, onToast}) {
const targetType = (hashParams && hashParams.target_type) || 'user';
const targetName = (hashParams && hashParams.target_name) || '';
const [reasons, setReasons] = React.useState(null);
React.useEffect(() => {
getAbuseReasons(targetType).then(data => setReasons(Array.isArray(data) ? data : (data && (data.reasons || data.items)) || []))
.catch(err => { onToast(err.message || '加载失败'); setReasons([]); });
}, [targetType]);
const select = reason => navigate('abuse-report-details', {...hashParams, reason_code: reason.reason_code || reason.code, reason_label: reason.label, requires_description: reason.requires_description ? '1' : ''});
{/* 'personal-center' made sense when contact-management-view.jsx's friend-settings page was the
only entry point; tinode-web.jsx's handleReportTopic (block-and-report from inside a chat,
which closes that chat before landing here) now also routes here, and for that origin
'personal-center' is a non-sequitur -- messages-home is a reasonable default for both. */}
return <div className="support-page"><Header title="举报" back="messages-home" /><main className="report-reasons">
{targetName ? <section className="report-target"><Icon>account_circle</Icon><span><strong>举报{targetName}</strong><small>举报人身份不会向对方公开</small></span></section> : null}
<h2>请选择举报原因</h2>
{reasons === null ? <p className="report-loading">加载中…</p> : <section className="report-reason-list">
{reasons.map(reason => <button type="button" key={reason.reason_code || reason.code} onClick={() => select(reason)}>
<span><strong>{reason.label}</strong></span><Icon>chevron_right</Icon>
</button>)}
</section>}
</main></div>;
} }
function ReportDetails({onToast}) { function ReportDetails({hashParams, tinode, onToast}) {
const reason = (() => {try { return JSON.parse(window.sessionStorage.getItem(REPORT_REASON_KEY)) || REPORT_REASONS[0]; } catch (_) { return REPORT_REASONS[0]; }})(); const reasonLabel = (hashParams && hashParams.reason_label) || '';
const reasonCode = (hashParams && hashParams.reason_code) || '';
const requiresDescription = (hashParams && hashParams.requires_description) === '1';
const [description, setDescription] = React.useState(''); const [description, setDescription] = React.useState('');
const [evidence, setEvidence] = React.useState([]); const [evidence, setEvidence] = React.useState([]);
const [submitted, setSubmitted] = React.useState(false); const [uploading, setUploading] = React.useState(false);
const addEvidence = () => {if (evidence.length >= 5) return onToast('最多上传 5 个证据文件'); setEvidence(items => items.concat({id: Date.now(), name: `聊天截图_${items.length + 1}.png`}));}; const [submitting, setSubmitting] = React.useState(false);
const submit = () => {if (reason.required && !description.trim()) return onToast('请补充问题描述'); setSubmitted(true);}; const [submitted, setSubmitted] = React.useState(null);
return <div className="support-page"><Header title="举报" back="abuse-report-reasons" />{submitted ? <main className="support-success"><Icon>task_alt</Icon><h2>举报已受理</h2><p>我们会依据平台规则核实处理,举报人身份不会向被举报方公开。</p><dl><div><dt>受理编号</dt><dd>ABR-20260825-1024</dd></div><div><dt>当前状态</dt><dd>审核中</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main> : <main className="report-details"><section className="report-selected-reason"><Icon>{reason.icon}</Icon><span><small>举报原因</small><strong>{reason.label}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons')}>修改</button></section><label className="support-textarea"><span>问题描述{reason.required ? <b>*</b> : null}</span><textarea value={description} maxLength="500" onChange={event => setDescription(event.target.value)} placeholder="请描述具体情况、发生时间和相关行为" /><small>{description.length}/500</small></label><section className="evidence-section"><header><span><strong>证据材料</strong><small>图片或文件,最多 5 个</small></span><em>{evidence.length}/5</em></header><div>{evidence.map(file => <article key={file.id}><Icon>image</Icon><span>{file.name}</span><button type="button" onClick={() => setEvidence(items => items.filter(item => item.id !== file.id))} aria-label="删除证据"><Icon>close</Icon></button></article>)}<button type="button" className="evidence-upload" onClick={addEvidence}><Icon>add_photo_alternate</Icon><span>添加证据</span></button></div></section><aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside></main>} {!submitted ? <footer className="support-submit-bar"><button type="button" onClick={submit}>提交举报</button></footer> : null}</div>; const fileInputRef = React.useRef(null);
const addEvidence = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (evidence.length >= 9) { onToast('最多上传 9 个证据文件'); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => setEvidence(items => items.concat({id: fileId, name: file.name})))
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (requiresDescription && !description.trim()) { onToast('请补充问题描述'); return; }
const target = {type: hashParams.target_type || 'user'};
if (hashParams.target_type === 'message') { target.topic = hashParams.topic; target.seq = String(hashParams.seq); }
else if (hashParams.target_type === 'group') { target.topic = hashParams.topic || hashParams.target_id; }
else { target.user_id = hashParams.target_id; }
setSubmitting(true);
submitAbuseReport({target, reason_code: reasonCode, description: description.trim(), evidence_file_ids: evidence.map(e => e.id)})
.then(result => setSubmitted(result))
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
return <div className="support-page"><Header title="举报" back="abuse-report-reasons" />
{submitted ? <main className="support-success"><Icon>task_alt</Icon><h2>举报已受理</h2><p>我们会依据平台规则核实处理,举报人身份不会向被举报方公开。</p><dl><div><dt>受理编号</dt><dd>{submitted.report_id}</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main>
: <main className="report-details">
<section className="report-selected-reason"><span><small>举报原因</small><strong>{reasonLabel}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons', hashParams)}>修改</button></section>
<label className="support-textarea"><span>问题描述{requiresDescription ? <b>*</b> : null}</span><textarea value={description} maxLength="500" onChange={event => setDescription(event.target.value)} placeholder="请描述具体情况、发生时间和相关行为" /><small>{description.length}/500</small></label>
<section className="evidence-section">
<header><span><strong>证据材料</strong><small>图片或文件,最多 9 个</small></span><em>{evidence.length}/9</em></header>
<div>
{evidence.map(file => <article key={file.id}><Icon>image</Icon><span>{file.name}</span><button type="button" onClick={() => setEvidence(items => items.filter(item => item.id !== file.id))} aria-label="删除证据"><Icon>close</Icon></button></article>)}
<button type="button" className="evidence-upload" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_photo_alternate</Icon><span>{uploading ? '上传中…' : '添加证据'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addEvidence} />
</div>
</section>
<aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside>
</main>}
{!submitted ? <footer className="support-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '提交中…' : '提交举报'}</button></footer> : null}
</div>;
} }
function UserFeedback({onToast}) { // category is REQUIRED server-side (POST /feedback rejects empty category) but the reference image
const [category, setCategory] = React.useState('bug'); // (意见反馈.png) shows no category-selection UI at all -- picking a sensible default silently would
// mean every feedback submission gets misfiled under one bucket. A minimal category picker is added
// here (using the real categories from GET /feedback/config, not the mock's invented four-item list)
// since it's a real, meaningful, backend-required field -- same judgment call as Ch.6's "my nickname
// in this group" editor: no reference image, but a real backend capability worth a minimal UI for.
// `contact` (image has no input for it) is left unset -- it's optional server-side, so omitting it
// doesn't break the request, and adding a field the design never showed would be inventing UI.
function UserFeedback({tinode, onToast}) {
// Fixed/parameterless config (max files, categories, max length, diagnostics notice) reached from a
// single entry point (personal-center's "意见反馈" row) off the same hub whose own profile fetch is
// already cached -- seeding both `config` and its derived default `category` together from the same
// cached object avoids a flash where the category picker renders (gated on `config` alone) with
// nothing selected yet.
const cachedFeedbackConfig = readCachedData('feedback-config');
const [config, setConfig] = React.useState(() => cachedFeedbackConfig || null);
const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
const [content, setContent] = React.useState(''); const [content, setContent] = React.useState('');
const [contact, setContact] = React.useState(''); const [images, setImages] = React.useState([]);
const [attachments, setAttachments] = React.useState(0); const [uploading, setUploading] = React.useState(false);
const [diagnostics, setDiagnostics] = React.useState(false); const [diagnostics, setDiagnostics] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
const submit = () => {if (!content.trim()) return onToast('请填写反馈内容'); setSubmitted(true);}; const [submitted, setSubmitted] = React.useState(null);
if (submitted) return <div className="support-page"><Header title="意见反馈" back="personal-center" /><main className="support-success"><Icon>mark_email_read</Icon><h2>反馈已受理</h2><p>感谢你的反馈,我们会尽快查看并用于改进产品。</p><dl><div><dt>反馈编号</dt><dd>FB-20260825-0836</dd></div><div><dt>提交信息</dt><dd>Android 1.2.4</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>; const fileInputRef = React.useRef(null);
const categories = [{id: 'bug', label: '功能异常', icon: 'bug_report'}, {id: 'suggestion', label: '产品建议', icon: 'lightbulb'}, {id: 'complaint', label: '服务投诉', icon: 'support_agent'}, {id: 'other', label: '其他', icon: 'more_horiz'}]; const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main><section className="feedback-categories"><h2>反馈类型</h2><div>{categories.map(item => <button type="button" className={category === item.id ? 'active' : ''} key={item.id} onClick={() => setCategory(item.id)}><Icon>{item.icon}</Icon><span>{item.label}</span></button>)}</div></section><label className="support-textarea"><span>问题或建议<b>*</b></span><textarea value={content} maxLength="1000" onChange={event => setContent(event.target.value)} placeholder="请详细描述你遇到的问题或建议" /><small>{content.length}/1000</small></label><section className="feedback-upload"><header><span><strong>截图或附件</strong><small>最多 5 个,单个不超过 20 MB</small></span><em>{attachments}/5</em></header><button type="button" onClick={() => attachments < 5 ? setAttachments(value => value + 1) : onToast('最多上传 5 个附件')}><Icon>add_photo_alternate</Icon><span>{attachments ? `已选择 ${attachments} 个附件` : '添加附件'}</span></button></section><label className="feedback-contact"><span>联系方式</span><input value={contact} maxLength="80" onChange={event => setContact(event.target.value)} placeholder="手机号或邮箱(选填)" /></label><label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>仅包含 Android 版本、应用版本和错误日志,不包含聊天内容、密码或 Token。</small></span></label></main><footer className="support-submit-bar"><button type="button" onClick={submit}>提交反馈</button></footer></div>; const maxLength = (config && config.max_length) || 300;
React.useEffect(() => {
getFeedbackConfig().then(data => {
writeCachedData('feedback-config', data);
setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');
}).catch(() => {});
}, []);
const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};
const addImage = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (images.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 张图片`); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => setImages(items => items.concat({id: fileId})))
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (!content.trim()) { onToast('请填写反馈内容'); return; }
setSubmitting(true);
submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics})
.then(result => setSubmitted(result))
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
if (submitted) return <div className="support-page"><Header title="意见反馈" back="personal-center" /><main className="support-success"><Icon>mark_email_read</Icon><h2>反馈已受理</h2><p>感谢你的反馈,我们会尽快查看并用于改进产品。</p><dl><div><dt>反馈编号</dt><dd>{submitted.feedback_id}</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>;
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main>
{config && config.categories && config.categories.length > 1 ? <section className="feedback-categories"><h2>反馈类型</h2><div>
{config.categories.map(id => <button type="button" className={category === id ? 'active' : ''} key={id} onClick={() => setCategory(id)}>{CATEGORY_LABELS[id] || id}</button>)}
</div></section> : null}
<label className="support-textarea">
<span>问题反馈<b>*</b></span>
<textarea value={content} maxLength={maxLength} onChange={event => setContent(event.target.value)} placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" />
<small>{content.length}/{maxLength}</small>
</label>
<section className="feedback-upload">
<header><span><strong>图片</strong></span><em>{images.length}/{maxFiles}</em></header>
<button type="button" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_a_photo</Icon><span>{uploading ? '上传中…' : '点击上传'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addImage} />
</section>
{config && config.diagnostics_notice ? <label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>{config.diagnostics_notice}</small></span></label> : null}
</main><footer className="support-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer></div>;
} }
export default function SecuritySupportView({view}) { export default function SecuritySupportView({view, hashParams, tinode}) {
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
let content; let content;
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <RiskWarning secondary={view === 'fraud-risk-confirmation'} onToast={setToast} />; if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <FraudAwareness secondary={view === 'fraud-risk-confirmation'} />;
else if (view === 'abuse-report-reasons') content = <ReportReasons />; else if (view === 'abuse-report-reasons') content = <ReportReasons hashParams={hashParams} onToast={setToast} />;
else if (view === 'abuse-report-details') content = <ReportDetails onToast={setToast} />; else if (view === 'abuse-report-details') content = <ReportDetails hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <UserFeedback onToast={setToast} />; else content = <UserFeedback tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>; return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
} }
+30 -9
View File
@@ -134,7 +134,20 @@ class SidepanelView extends React.PureComponent {
if (chapterOneViews.includes(view)) { if (chapterOneViews.includes(view)) {
return <div id="sidepanel" className="auth-onboarding-panel"> return <div id="sidepanel" className="auth-onboarding-panel">
<AuthOnboardingView view={view} authenticated={!!this.props.myUserId} /> <AuthOnboardingView
view={view}
authenticated={!!this.props.myUserId}
login={this.props.login}
loginDisabled={this.props.loginDisabled}
persist={this.props.persist}
errorText={this.props.errorText}
errorLevel={this.props.errorLevel}
onLoginRequest={this.props.onLoginRequest}
onPersistenceChange={this.props.onPersistenceChange}
onCreateAccount={this.props.onCreateAccount}
onImRegisterRequest={this.props.onImRegisterRequest}
onTenantResolved={this.props.onTenantResolved}
onError={this.props.onError} />
</div>; </div>;
} }
@@ -147,7 +160,8 @@ class SidepanelView extends React.PureComponent {
connected={this.props.connected} connected={this.props.connected}
chatList={this.props.chatList} chatList={this.props.chatList}
myUserId={this.props.myUserId} myUserId={this.props.myUserId}
onTopicSelected={this.props.onTopicSelected} /> onTopicSelected={this.props.onTopicSelected}
onOpenScanner={this.props.onOpenScanner} />
</div>; </div>;
} }
@@ -168,19 +182,22 @@ class SidepanelView extends React.PureComponent {
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) { if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
return <div id="sidepanel" className="contact-management-panel"> return <div id="sidepanel" className="contact-management-panel">
<ContactManagementView view={view} key={view} /> <ContactManagementView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} onOpenScanner={this.props.onOpenScanner} key={view} />
</div>; </div>;
} }
if (GROUP_MANAGEMENT_VIEWS.includes(view)) { if (GROUP_MANAGEMENT_VIEWS.includes(view)) {
return <div id="sidepanel" className="group-management-panel"> return <div id="sidepanel" className="group-management-panel">
<GroupManagementView view={view} key={view} /> <GroupManagementView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} key={view} />
</div>; </div>;
} }
if (CONTENT_LIBRARY_VIEWS.includes(view)) { if (CONTENT_LIBRARY_VIEWS.includes(view)) {
return <div id="sidepanel" className="content-library-panel"> return <div id="sidepanel" className="content-library-panel">
<ContentLibraryView view={view} key={view} /> <ContentLibraryView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} chatList={this.props.chatList} key={view} />
</div>; </div>;
} }
@@ -192,19 +209,23 @@ class SidepanelView extends React.PureComponent {
if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) { if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) {
return <div id="sidepanel" className="workplace-collaboration-panel"> return <div id="sidepanel" className="workplace-collaboration-panel">
<WorkplaceCollaborationView view={view} key={view} /> <WorkplaceCollaborationView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} key={view} />
</div>; </div>;
} }
if (PERSONAL_SETTINGS_VIEWS.includes(view)) { if (PERSONAL_SETTINGS_VIEWS.includes(view)) {
return <div id="sidepanel" className="personal-settings-panel"> return <div id="sidepanel" className="personal-settings-panel">
<PersonalSettingsView view={view} key={view} /> <PersonalSettingsView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
tinode={this.props.tinode} chatList={this.props.chatList}
onUpdatePassword={this.props.onUpdatePassword} onDeleteAccount={this.props.onDeleteAccount}
onLogout={this.props.onLogout} key={view} />
</div>; </div>;
} }
if (SECURITY_SUPPORT_VIEWS.includes(view)) { if (SECURITY_SUPPORT_VIEWS.includes(view)) {
return <div id="sidepanel" className="security-support-panel"> return <div id="sidepanel" className="security-support-panel">
<SecuritySupportView view={view} key={view} /> <SecuritySupportView view={view} hashParams={this.props.hashParams} tinode={this.props.tinode} key={view} />
</div>; </div>;
} }
@@ -331,7 +352,7 @@ class SidepanelView extends React.PureComponent {
onLogout={this.props.onLogout} onLogout={this.props.onLogout}
onDeleteAccount={this.props.onDeleteAccount} onDeleteAccount={this.props.onDeleteAccount}
onShowAlert={this.props.onShowAlert} onShowAlert={this.props.onShowAlert}
onShowBlocked={() => this.props.onNavigate('block-list')} onShowBlocked={this.props.onShowBlocked}
onToggleIncognitoMode={this.props.onToggleIncognitoMode} /> onToggleIncognitoMode={this.props.onToggleIncognitoMode} />
</Suspense> : </Suspense> :
+344 -21
View File
@@ -11,7 +11,9 @@ import { Drafty, Tinode } from 'tinode-sdk';
import Alert from '../widgets/alert.jsx'; import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx'; import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx'; import ForwardDialog from '../widgets/forward-dialog.jsx';
import BatchForwardDialog from '../widgets/batch-forward-dialog.jsx';
import CallIncoming from '../widgets/call-incoming.jsx'; import CallIncoming from '../widgets/call-incoming.jsx';
import QrScanner, { GroupJoinPreview } from '../widgets/qr-scanner.jsx';
const PhoneCountrySelector = React.lazy(_ => import('../widgets/phone-country-selector.jsx')); const PhoneCountrySelector = React.lazy(_ => import('../widgets/phone-country-selector.jsx'));
const InfoView = React.lazy(_ => import('./info-view.jsx')); const InfoView = React.lazy(_ => import('./info-view.jsx'));
@@ -27,9 +29,11 @@ import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js'; import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js'; import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js'; import LocalStorageUtil from '../lib/local-storage.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest } from '../lib/im-api.js';
import { writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js' import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, updateFavicon } from '../lib/utils.js'; import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
// Sound to play on message received. // Sound to play on message received.
const POP_SOUND = new Audio('audio/msg.m4a'); const POP_SOUND = new Audio('audio/msg.m4a');
@@ -119,6 +123,7 @@ class TinodeWeb extends React.Component {
this.handleAutoreconnectIteration = this.handleAutoreconnectIteration.bind(this); this.handleAutoreconnectIteration = this.handleAutoreconnectIteration.bind(this);
this.doLogin = this.doLogin.bind(this); this.doLogin = this.doLogin.bind(this);
this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this); this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
this.handleTenantResolved = this.handleTenantResolved.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this); this.handleDisconnect = this.handleDisconnect.bind(this);
this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this); this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this); this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
@@ -133,6 +138,7 @@ class TinodeWeb extends React.Component {
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this); this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
this.handleNewAccount = this.handleNewAccount.bind(this); this.handleNewAccount = this.handleNewAccount.bind(this);
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this); this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
this.handleImRegisterRequest = this.handleImRegisterRequest.bind(this);
this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this); this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this);
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this); this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this); this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
@@ -182,6 +188,18 @@ class TinodeWeb extends React.Component {
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this); this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this); this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
this.handleEnterSelectMode = this.handleEnterSelectMode.bind(this);
this.handleToggleMessageSelect = this.handleToggleMessageSelect.bind(this);
this.handleExitSelectMode = this.handleExitSelectMode.bind(this);
this.handleShowBatchForward = this.handleShowBatchForward.bind(this);
this.handleHideBatchForward = this.handleHideBatchForward.bind(this);
this.handleOpenScanner = this.handleOpenScanner.bind(this);
this.handleCloseScanner = this.handleCloseScanner.bind(this);
this.handleQrDetected = this.handleQrDetected.bind(this);
this.handleSubmitGroupJoinRequest = this.handleSubmitGroupJoinRequest.bind(this);
this.handleCancelGroupJoinPreview = this.handleCancelGroupJoinPreview.bind(this);
this.handleStartVideoCall = this.handleStartVideoCall.bind(this); this.handleStartVideoCall = this.handleStartVideoCall.bind(this);
this.handleStartAudioCall = this.handleStartAudioCall.bind(this); this.handleStartAudioCall = this.handleStartAudioCall.bind(this);
this.handleInfoMessage = this.handleInfoMessage.bind(this); this.handleInfoMessage = this.handleInfoMessage.bind(this);
@@ -253,6 +271,7 @@ class TinodeWeb extends React.Component {
errorActionText: null, errorActionText: null,
sidePanelSelected: 'tenant', sidePanelSelected: 'tenant',
sidePanelParams: {},
sidePanelTitle: null, sidePanelTitle: null,
sidePanelAvatar: null, sidePanelAvatar: null,
myTrustedBadges: [], myTrustedBadges: [],
@@ -289,6 +308,21 @@ class TinodeWeb extends React.Component {
forwardDialogVisible: false, forwardDialogVisible: false,
forwardMessage: null, forwardMessage: null,
// Ch.4 multi-select: entered via the message context menu's "menu_item_select" item.
selectMode: false,
selectModeTopic: null,
selectedSeqs: [],
batchForwardVisible: false,
// QR scanning ("扫一扫", Ch.2/5). Holds live camera state so it's modeled the same way as
// the call panel/forward dialog above, not as a hash route -- there's nothing to deep-link
// into mid-scan, and the camera stream needs a reliable single owner to tear down.
scannerOpen: false,
// Set once a "join_group" QR resolves to a real group the user isn't in yet (status
// "can_request") -- holds enough of the group's summary to render the confirm sheet, plus
// the scanned token so the join request can reference how it was created.
groupJoinPreview: null,
// Modal alert dialog. // Modal alert dialog.
alertVisible: false, alertVisible: false,
alertParams: {}, alertParams: {},
@@ -357,7 +391,12 @@ class TinodeWeb extends React.Component {
}); });
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(), this.state.transport, // Must match state.secureConnection's own fallback (line ~233), not just isSecureConnection()
// directly -- otherwise a tenant resolved on a prior visit (which persists secureConnection via
// handleTenantResolved) diverges from the client actually constructed here on this load, and
// handleTenantResolved's own "did anything change" check (comparing against this same state)
// then wrongly concludes there's nothing to reconnect.
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection, this.state.transport,
this.props.intl.locale, this.state.persist, resolve); this.props.intl.locale, this.state.persist, resolve);
this.tinode.onConnect = this.handleConnected; this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onDisconnect = this.handleDisconnect;
@@ -423,9 +462,46 @@ class TinodeWeb extends React.Component {
secure: secureConnection, persist: persistentCache}, onSetupCompleted); secure: secureConnection, persist: persistentCache}, onSetupCompleted);
tinode.setHumanLanguage(locale); tinode.setHumanLanguage(locale);
tinode.enableLogging(LOGGING_ENABLED, true); tinode.enableLogging(LOGGING_ENABLED, true);
TinodeWeb.installTenantHiPatch(tinode);
return tinode; return tinode;
} }
// The server extends two stock packets with fields the JS SDK has no API for (server/datamodel.go):
// MsgClientHi.Tenant (`hi.tenant`, resolved via POST /v1/im/tenants/resolve) and MsgClientAcc's
// `username`/`phone`/`invite_code` (used by server/user.go replyCreateUser during registration).
// #initPacket is a private class field so the only usable hook is wrapping the connection's sendText
// and injecting fields into the outgoing packet's already-serialized JSON. A cheap substring check
// keeps this a no-op for the hot path (every {pub}/{note}/... also goes through sendText).
static installTenantHiPatch(tinode) {
const conn = tinode && tinode._connection;
if (!conn || conn.__tenantPatched) {
return;
}
const original = conn.sendText.bind(conn);
conn.sendText = msg => {
if (typeof msg == 'string' && (msg.indexOf('"hi":') >= 0 || msg.indexOf('"acc":') >= 0)) {
try {
const parsed = JSON.parse(msg);
if (parsed.hi) {
const tenant = getTenantContext();
if (tenant && tenant.code) {
parsed.hi.tenant = tenant.code;
msg = JSON.stringify(parsed);
}
} else if (parsed.acc && tinode.__pendingAccExtra) {
Object.assign(parsed.acc, tinode.__pendingAccExtra);
tinode.__pendingAccExtra = null;
msg = JSON.stringify(parsed);
}
} catch (e) {
// Not valid JSON -- send as is.
}
}
return original(msg);
};
conn.__tenantPatched = true;
}
// Tinode received a push notification from the server. // Tinode received a push notification from the server.
handlePushMessage(payload) { handlePushMessage(payload) {
this.tinode.oobNotification(payload.data || {}); this.tinode.oobNotification(payload.data || {});
@@ -593,7 +669,7 @@ class TinodeWeb extends React.Component {
'group-list','group-empty','group-invite','group-events','group-join-requests', 'group-list','group-empty','group-invite','group-events','group-join-requests',
'group-new-request','group-announcement','group-summary','group-announcement-detail', 'group-new-request','group-announcement','group-summary','group-announcement-detail',
'group-settings','group-profile','group-avatar','group-nickname','group-description', 'group-settings','group-profile','group-avatar','group-nickname','group-description',
'group-role-message','group-qr','group-governance','group-admins','group-admin-add', 'group-my-nickname','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add', 'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload', 'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice', 'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
@@ -603,19 +679,22 @@ class TinodeWeb extends React.Component {
'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active', 'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active',
'video-call-peer-camera-off','call-minimized', 'video-call-peer-camera-off','call-minimized',
'workbench','organization-directory','meeting-organizer','meeting-attendee', 'workbench','organization-directory','meeting-organizer','meeting-attendee',
'meeting-create','meeting-detail',
'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail', 'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail',
'monthly-report-editor','monthly-report-list','monthly-report-detail', 'monthly-report-editor','monthly-report-list','monthly-report-detail',
'executive-mailbox','executive-mailbox-notice', 'executive-mailbox','executive-mailbox-notice',
'personal-center','personal-profile','profile-nickname','profile-birth-date', 'personal-center','personal-profile','profile-nickname','profile-birth-date',
'profile-signature','personal-qrcode','android-settings','cache-clear-confirm', 'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings',
'app-version','app-update-available','privacy-settings','friend-discovery-settings', 'cache-clear-confirm','clear-history-confirm','change-password','delete-account-confirm',
'youth-mode-off','youth-mode-confirm','youth-mode-on','youth-mode-disable', 'app-version','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-on','youth-mode-verify',
'notification-permission','permission-settings-reminder','camera-permission-info', 'notification-permission','permission-settings-reminder','camera-permission-info',
'microphone-permission-info', 'microphone-permission-info',
'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons', 'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons',
'abuse-report-details','user-feedback', 'abuse-report-details','user-feedback',
''].includes(hash.path[0])) { ''].includes(hash.path[0])) {
newState.sidePanelSelected = hash.path[0]; newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else { } else {
console.warn("Unknown sidepanel view", hash.path[0]); console.warn("Unknown sidepanel view", hash.path[0]);
} }
@@ -638,7 +717,7 @@ class TinodeWeb extends React.Component {
} }
} else { } else {
// Empty hashpath // Empty hashpath
Object.assign(newState, {sidePanelSelected: '', topicSelected: null}); Object.assign(newState, {sidePanelSelected: '', sidePanelParams: {}, topicSelected: null});
} }
// Save credential validation parameters, if available. // Save credential validation parameters, if available.
@@ -890,6 +969,11 @@ class TinodeWeb extends React.Component {
if (LocalStorageUtil.getObject('keep-logged-in')) { if (LocalStorageUtil.getObject('keep-logged-in')) {
LocalStorageUtil.setObject('auth-token', this.tinode.getAuthToken()); LocalStorageUtil.setObject('auth-token', this.tinode.getAuthToken());
} }
// im-api.js's REST calls (bootstrap, friends, ...) need a bearer token independent of whether the
// user opted into surviving a browser restart -- sessionStorage covers "this tab's session",
// matching how long the in-memory WS auth actually lasts when "keep logged in" is off.
const authToken = this.tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
// On reconnect, requestedTopic is undefined but topicSelected may still be set. // On reconnect, requestedTopic is undefined but topicSelected may still be set.
// Preserve the open topic so it gets restored after re-login. // Preserve the open topic so it gets restored after re-login.
@@ -924,7 +1008,10 @@ class TinodeWeb extends React.Component {
}).finally(_ => { }).finally(_ => {
this.setState({loadSpinnerVisible: false}); this.setState({loadSpinnerVisible: false});
}); });
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'contacts'); // The new bottom nav only has "workbench" and "messages" -- there's no standalone contacts
// entry point anymore, so landing a fresh login there orphaned the user outside the nav they can
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
let urlHash = HashNavigation.setUrlSidePanel(window.location.hash, 'workbench');
if (goToTopic) { if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic); urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
} }
@@ -1310,6 +1397,23 @@ class TinodeWeb extends React.Component {
}); });
} }
// Registration via the product's username/phone flow (chapter 1.4). `extra` carries the fields the
// server added to {acc} (username/phone/invite_code, see server/user.go resolveRegistrationAccount) --
// the SDK has no API for them, so they're injected by the sendText patch installed in tnSetup().
// `cred_` (when phone was verified through POST /v1/im/auth/verification-codes) is
// {meth:'tel', val, resp: verification_token, params: {challenge_id}} -- see server/user.go
// consumeProductVerificationCred, which validates resp/params against the REST-issued token.
//
// `login_` must equal what the server's own resolveRegistrationAccount will compute as the
// account name, or replyCreateUser rejects the whole request with a policy violation before it
// even looks at username/phone/cred: for phone registration that's the E.164 phone (not blank --
// registrationSecretMatches requires a non-empty match), for username registration it's the
// username itself.
handleImRegisterRequest(login_, password_, displayName, extra, cred_) {
this.tinode.__pendingAccExtra = extra;
this.handleNewAccountRequest(login_, password_, theCard(displayName, null), cred_);
}
handleToggleIncognitoMode(on) { handleToggleIncognitoMode(on) {
// Make state undefined. // Make state undefined.
this.setState({incognitoMode: null}); this.setState({incognitoMode: null});
@@ -1424,6 +1528,36 @@ class TinodeWeb extends React.Component {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, '')); HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, ''));
} }
// Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry).
// Persists the tenant context (read by im-api.js and by the {hi}-patching in tnSetup) and, if the
// resolved tenant genuinely lives on a different host, rebuilds the Tinode client to point there.
handleTenantResolved(data, requestedCode) {
const tenant = data.tenant || {};
const endpoints = data.endpoints || {};
LocalStorageUtil.setObject('im-tenant', {
code: tenant.code || requestedCode,
name: tenant.name || tenant.code || requestedCode,
endpoints
});
let host = this.state.serverAddress;
let secureConnection = this.state.secureConnection;
if (endpoints.websocket) {
try {
const wsUrl = new URL(endpoints.websocket);
host = wsUrl.host;
secureConnection = wsUrl.protocol === 'wss:';
} catch (e) {
// Malformed endpoint; keep the current host/scheme.
}
}
if (host && (host !== this.state.serverAddress || secureConnection !== this.state.secureConnection)) {
this.handleGlobalSettings({serverAddress: host, secureConnection});
}
// Else: same host/scheme, nothing to reconnect -- the sendText patch installed in tnSetup() reads
// the tenant context fresh on every {hi}, so the newly-persisted code takes effect immediately.
}
// User chose 'Archived chats'. // User chose 'Archived chats'.
handleShowArchive() { handleShowArchive() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
@@ -1642,6 +1776,7 @@ class TinodeWeb extends React.Component {
localStorage.removeItem('auth-token'); localStorage.removeItem('auth-token');
localStorage.removeItem('firebase-token'); localStorage.removeItem('firebase-token');
localStorage.removeItem('settings'); localStorage.removeItem('settings');
sessionStorage.removeItem('im-session-token');
if (this.state.firebaseToken && this.fcm) { if (this.state.firebaseToken && this.fcm) {
// Unsubscribe failures (e.g. 403 token-unsubscribe-failed) should not block logout. // Unsubscribe failures (e.g. 403 token-unsubscribe-failed) should not block logout.
firebaseDelToken(this.fcm).catch(err => { firebaseDelToken(this.fcm).catch(err => {
@@ -1666,7 +1801,9 @@ class TinodeWeb extends React.Component {
this.setState(this.getBlankState()); this.setState(this.getBlankState());
cleared.then(_ => { cleared.then(_ => {
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, isSecureConnection(), // Same fix as the initial tnSetup call in componentDidMount: use the resolved
// state.secureConnection (settings-aware), not a raw isSecureConnection() page-protocol check.
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.secureConnection,
this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), _ => { this.state.transport, this.props.intl.locale, LocalStorageUtil.getObject('keep-logged-in'), _ => {
this.tinode.onConnect = this.handleConnected; this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onDisconnect = this.handleDisconnect;
@@ -1679,7 +1816,7 @@ class TinodeWeb extends React.Component {
} }
handleDeleteAccount() { handleDeleteAccount() {
this.tinode.delCurrentUser(true).then(_ => { return this.tinode.delCurrentUser(true).then(_ => {
this.handleLogout(); this.handleLogout();
}); });
} }
@@ -1744,14 +1881,24 @@ class TinodeWeb extends React.Component {
return; return;
} }
// Publish spam report. // The native `this.tinode.report('report', topicName)` call (WS pub to the "sys" topic) requires
this.tinode.report('report', topicName); // auth.LevelRoot server-side (server/session.go) which a normal logged-in user never has -- it
// silently fails permission checks and never reaches the abuse-report tables. Route to the real
// REST reporting flow (im-api.js's getAbuseReasons/submitAbuseReport, backed by POST /reports/abuse)
// instead of the old dead-end call.
const isP2P = topic.isP2PType();
const targetName = topicDisplayName(topicName, topic.public, topicName);
const params = isP2P
? {target_type: 'user', target_id: topicName, target_name: targetName}
: {target_type: 'group', target_id: topicName, topic: topicName, target_name: targetName};
// Remove J and P permissions. // Remove J and P permissions (the "block" half of "block and report").
topic.updateMode(null, '-JP') topic.updateMode(null, '-JP')
.then(_ => { .then(_ => {
// Hide MessagesView and InfoView panels. let hash = HashNavigation.setUrlTopic(window.location.hash, '');
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, '')); hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); });
HashNavigation.navigateTo(hash);
}) })
.catch(err => this.handleError(err.message, 'err')); .catch(err => this.handleError(err.message, 'err'));
} }
@@ -1876,9 +2023,113 @@ class TinodeWeb extends React.Component {
} }
} else if (action == 'menu_item_forward') { } else if (action == 'menu_item_forward') {
this.handleShowForwardDialog(params); this.handleShowForwardDialog(params);
} else if (action == 'menu_item_select') {
this.handleEnterSelectMode(params);
} }
} }
// Ch.4 multi-select: pre-selects the message that was long-pressed to reach "多选".
handleEnterSelectMode(params) {
this.setState({
selectMode: true,
selectModeTopic: params.topicName,
selectedSeqs: [params.seq]
});
}
handleToggleMessageSelect(seq) {
const selectedSeqs = this.state.selectedSeqs.includes(seq) ?
this.state.selectedSeqs.filter(s => s != seq) :
this.state.selectedSeqs.concat(seq);
this.setState({selectedSeqs});
}
handleExitSelectMode() {
this.setState({selectMode: false, selectModeTopic: null, selectedSeqs: [], batchForwardVisible: false});
}
handleShowBatchForward() {
this.setState({batchForwardVisible: true});
}
handleHideBatchForward() {
this.setState({batchForwardVisible: false});
}
handleOpenScanner() {
this.setState({scannerOpen: true});
}
handleCloseScanner() {
this.setState({scannerOpen: false});
}
// `text` is whatever jsQR decoded from the camera frame -- only codes this app itself generated
// (createMyQRCode/createGroupQRCode, both `im://qr?token=...&purpose=...`) resolve to anything;
// a QR code from the outside world (a URL, a WiFi code) is reported as unrecognized rather than
// silently failing the resolve call with a confusing server error.
handleQrDetected(text) {
const hasScheme = typeof text === 'string' && text.indexOf('im://qr') === 0;
const params = hasScheme ? new URLSearchParams(text.slice(text.indexOf('?') + 1)) : null;
const token = params ? params.get('token') : null;
const purpose = params ? params.get('purpose') : null;
if (!token) {
this.setState({scannerOpen: false});
this.handleError('无法识别的二维码', 'err');
return;
}
resolveQRCode(token, purpose).then(data => {
if (data.purpose === 'join_group' && data.group) {
if (data.status === 'joined') {
this.setState({scannerOpen: false});
this.handleTopicSelected(data.group.topic);
} else {
this.setState({scannerOpen: false, groupJoinPreview: {...data.group, qrToken: token}});
}
return;
}
if (data.user) {
this.setState({scannerOpen: false});
// The profile page this navigates to re-fetches via GET /users/{id}/profile, which 403s for
// anyone who isn't already a friend (server-side privacy default) -- but resolveQRCode's
// response already carries the same fields unconditionally, so stash it under the same
// cache key contact-management-view.jsx's Profile/Verification read on mount, the same fix
// applied there for the account-search entry point into this identical dead end.
writeCachedData('profile-seed:' + data.user.user_id, {
...data.user,
capabilities: {can_add_friend: !!data.user.can_add}
});
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id);
HashNavigation.navigateTo(hash);
return;
}
this.setState({scannerOpen: false});
this.handleError('无法识别的二维码', 'err');
}).catch(err => {
this.setState({scannerOpen: false});
this.handleError(err.message || '二维码已失效', 'err');
});
}
handleSubmitGroupJoinRequest(message) {
const preview = this.state.groupJoinPreview;
if (!preview) {
return Promise.resolve();
}
return createGroupJoinRequest(preview.topic, {message, source: 'qr', qrToken: preview.qrToken}).then(() => {
this.setState({groupJoinPreview: null});
this.handleError('申请已提交,等待管理员通过', 'info');
}).catch(err => {
this.handleError(err.message || '申请提交失败', 'err');
});
}
handleCancelGroupJoinPreview() {
this.setState({groupJoinPreview: null});
}
handleShowAlert(title, content, onConfirm, confirmText, onReject, rejectText) { handleShowAlert(title, content, onConfirm, confirmText, onReject, rejectText) {
this.setState({ this.setState({
alertVisible: true, alertVisible: true,
@@ -1893,7 +2144,35 @@ class TinodeWeb extends React.Component {
}); });
} }
// The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx)
// -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx
// "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across
// Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached
// from an open chat's own header -- every test of them this engagement typed the hash directly.
// InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed
// parent per panel and never used real browser history, so routing here fixed both problems at once
// rather than teaching the old view a second back-navigation scheme it's slated for removal anyway.
handleShowInfoView() { handleShowInfoView() {
const topicName = this.state.topicSelected;
if (Tinode.isP2PTopicName(topicName)) {
// Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds
// the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current
// hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
// friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a
// fresh topic selection, blanking the whole view -- the exact bug already found and fixed for
// handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here.
let hash = HashNavigation.setUrlSidePanel('', 'friend-settings');
hash = HashNavigation.addUrlParam(hash, 'uid', topicName);
HashNavigation.navigateTo(hash);
return;
}
if (!Tinode.isSelfTopicName(topicName)) {
let hash = HashNavigation.setUrlSidePanel('', 'group-settings');
hash = HashNavigation.addUrlParam(hash, 'topic', topicName);
HashNavigation.navigateTo(hash);
return;
}
// "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it.
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info')); HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
this.setState({infoPanel: 'info'}); this.setState({infoPanel: 'info'});
} }
@@ -2179,7 +2458,7 @@ class TinodeWeb extends React.Component {
const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events', const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events',
'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary',
'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar',
'group-nickname', 'group-description', 'group-role-message', 'group-qr', 'group-governance', 'group-nickname', 'group-description', 'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance',
'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial',
'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select',
'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView); 'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView);
@@ -2192,14 +2471,15 @@ class TinodeWeb extends React.Component {
'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off',
'call-minimized'].includes(activeProductView); 'call-minimized'].includes(activeProductView);
const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer', const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer',
'meeting-attendee', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team',
'daily-report-detail', 'monthly-report-editor', 'monthly-report-list', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-list',
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView); 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname', const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
'profile-birth-date', 'profile-signature', 'personal-qrcode', 'android-settings', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
'cache-clear-confirm', 'app-version', 'app-update-available', 'privacy-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on', 'app-version', 'privacy-settings',
'youth-mode-disable', 'notification-permission', 'permission-settings-reminder', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
'notification-permission', 'permission-settings-reminder',
'camera-permission-info', 'microphone-permission-info'].includes(activeProductView); 'camera-permission-info', 'microphone-permission-info'].includes(activeProductView);
const securitySupportActive = ['fraud-risk-warning', 'fraud-risk-confirmation', const securitySupportActive = ['fraud-risk-warning', 'fraud-risk-confirmation',
'abuse-report-reasons', 'abuse-report-details', 'user-feedback'].includes(activeProductView); 'abuse-report-reasons', 'abuse-report-details', 'user-feedback'].includes(activeProductView);
@@ -2240,6 +2520,24 @@ class TinodeWeb extends React.Component {
: :
null null
} }
{this.state.batchForwardVisible ?
<BatchForwardDialog
tinode={this.tinode}
contacts={this.state.chatList}
topicSelected={this.state.topicSelected}
myUserId={this.state.myUserId}
topicName={this.state.selectModeTopic}
seqs={this.state.selectedSeqs}
hide={sent => { this.handleHideBatchForward(); if (sent) { this.handleExitSelectMode(); } }}
onInitFind={this.tnInitFind}
searchResults={this.state.searchResults}
onSearchContacts={this.handleSearchContacts}
/>
:
null
}
{this.state.callTopic && this.state.callState == CALL_STATE_INCOMING_RECEIVED ? {this.state.callTopic && this.state.callState == CALL_STATE_INCOMING_RECEIVED ?
<CallIncoming <CallIncoming
tinode={this.tinode} tinode={this.tinode}
@@ -2255,6 +2553,20 @@ class TinodeWeb extends React.Component {
: :
null null
} }
{this.state.scannerOpen ?
<QrScanner onDetect={this.handleQrDetected} onClose={this.handleCloseScanner} />
:
null
}
{this.state.groupJoinPreview ?
<GroupJoinPreview
group={this.state.groupJoinPreview}
onSubmit={this.handleSubmitGroupJoinRequest}
onCancel={this.handleCancelGroupJoinPreview}
/>
:
null
}
{this.state.alertVisible ? {this.state.alertVisible ?
<Alert <Alert
title={this.state.alertParams.title} title={this.state.alertParams.title}
@@ -2272,6 +2584,7 @@ class TinodeWeb extends React.Component {
connected={this.state.connected} connected={this.state.connected}
displayMobile={this.state.displayMobile} displayMobile={this.state.displayMobile}
state={this.state.sidePanelSelected} state={this.state.sidePanelSelected}
hashParams={this.state.sidePanelParams}
title={this.state.sidePanelTitle} title={this.state.sidePanelTitle}
avatar={this.state.sidePanelAvatar} avatar={this.state.sidePanelAvatar}
trustedBadges={this.state.myTrustedBadges} trustedBadges={this.state.myTrustedBadges}
@@ -2314,8 +2627,10 @@ class TinodeWeb extends React.Component {
onSettings={this.handleSettings} onSettings={this.handleSettings}
onNavigate={this.basicNavigator} onNavigate={this.basicNavigator}
onLoginRequest={this.handleLoginRequest} onLoginRequest={this.handleLoginRequest}
onTenantResolved={this.handleTenantResolved}
onPersistenceChange={this.handlePersistenceChange} onPersistenceChange={this.handlePersistenceChange}
onCreateAccount={this.handleNewAccountRequest} onCreateAccount={this.handleNewAccountRequest}
onImRegisterRequest={this.handleImRegisterRequest}
onUpdateAccountDesc={this.handleTopicUpdateRequest} onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest} onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest} onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
@@ -2331,6 +2646,7 @@ class TinodeWeb extends React.Component {
onCredDelete={this.handleCredDelete} onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm} onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected} onTopicSelected={this.handleTopicSelected}
onOpenScanner={this.handleOpenScanner}
onCreateTopic={this.handleStartTopicRequest} onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout} onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount} onDeleteAccount={this.handleDeleteAccount}
@@ -2364,6 +2680,7 @@ class TinodeWeb extends React.Component {
viewportWidth={this.state.viewportWidth} viewportWidth={this.state.viewportWidth}
viewportHeight={this.state.viewportHeight} viewportHeight={this.state.viewportHeight}
topic={this.state.topicSelected} topic={this.state.topicSelected}
jumpToSeq={this.state.sidePanelParams && this.state.sidePanelParams.jumpseq}
myUserId={this.state.myUserId} myUserId={this.state.myUserId}
// User public.fn. // User public.fn.
myUserName={this.state.sidePanelTitle} myUserName={this.state.sidePanelTitle}
@@ -2377,6 +2694,12 @@ class TinodeWeb extends React.Component {
forwardMessage={this.state.forwardMessage} forwardMessage={this.state.forwardMessage}
onCancelForwardMessage={this.handleHideForwardDialog} onCancelForwardMessage={this.handleHideForwardDialog}
selectMode={this.state.selectMode && this.state.selectModeTopic == this.state.topicSelected}
selectedSeqs={this.state.selectedSeqs}
onToggleMessageSelect={this.handleToggleMessageSelect}
onExitSelectMode={this.handleExitSelectMode}
onShowBatchForward={this.handleShowBatchForward}
callTopic={this.state.callTopic} callTopic={this.state.callTopic}
callSeq={this.state.callSeq} callSeq={this.state.callSeq}
callState={this.state.callState} callState={this.state.callState}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import FileProgress from './file-progress.jsx'; import FileProgress from './file-progress.jsx';
import { createFileDownloader } from '../lib/im-api.js';
import { bytesToHumanSize } from '../lib/strformat.js'; import { bytesToHumanSize } from '../lib/strformat.js';
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js'; import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
@@ -24,7 +25,11 @@ export default class Attachment extends React.Component {
return; return;
} }
const downloader = this.props.tinode.getLargeFileHelper(); // Same wrong-header bug as content-library-view.jsx's DownloadPage (X-Tinode-APIKey/
// X-Tinode-Auth, neither recognized by this backend) -- confirmed live: clicking "保存" on any
// real chat file attachment 403'd with "valid API key required". createFileDownloader() is the
// fixed-headers drop-in with the same download()/cancel() interface.
const downloader = createFileDownloader(this.props.tinode);
this.setState({downloader: downloader}); this.setState({downloader: downloader});
downloader.download(url, filename, mimetype, downloader.download(url, filename, mimetype,
loaded => this.setState({progress: loaded / this.props.size}), loaded => this.setState({progress: loaded / this.props.size}),
+166
View File
@@ -0,0 +1,166 @@
// Batch Forward Dialog: reached from multi-select mode (Ch.4 4.17-4.19). Distinct from
// ForwardDialog (the single-message quick-forward from the message's own context menu, which
// stays on the native Tinode pub/sub path) -- this one posts to the dedicated
// POST /v1/im/messages/forward HTTP endpoint since it needs to fan out to several targets in
// one call, matching docs/images/转发-单选.png / 转发-多选.png / 转发—逐条转发.png.
import React from 'react';
import { Tinode } from 'tinode-sdk';
import ContactList from './contact-list.jsx';
import SearchContacts from './search-contacts.jsx';
import { forwardMessages } from '../lib/im-api.js';
import { topicDisplayName } from '../lib/utils.js';
export default class BatchForwardDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
query: null,
multiSelect: false,
selectedTargets: [],
confirmTargets: null,
sending: false,
error: null
};
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleToggleMultiSelect = this.handleToggleMultiSelect.bind(this);
this.handleConfirmTargets = this.handleConfirmTargets.bind(this);
this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
this.handleSend = this.handleSend.bind(this);
}
componentDidMount() {
this.props.onInitFind();
document.addEventListener('keyup', this.handleEscapeKey, false);
}
componentWillUnmount() {
document.removeEventListener('keyup', this.handleEscapeKey, false);
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
this.props.hide();
}
}
handleClose(e) {
e.preventDefault();
this.props.hide();
}
handleSearchContacts(query) {
this.setState({query: Tinode.isNullValue(query) ? null : query});
this.props.onSearchContacts(query);
}
handleToggleMultiSelect(e) {
e.preventDefault();
this.setState(s => ({multiSelect: !s.multiSelect, selectedTargets: []}));
}
// Single-select: picking a contact goes straight to the confirm sheet (docs/images/转发-单选.png).
// Multi-select: toggles membership; "确定" (separate button below) advances to confirm.
handleContactSelected(uid) {
if (!this.state.multiSelect) {
this.setState({confirmTargets: [uid]});
return;
}
this.setState(s => ({
selectedTargets: s.selectedTargets.includes(uid) ?
s.selectedTargets.filter(t => t != uid) : s.selectedTargets.concat(uid)
}));
}
handleConfirmTargets(e) {
e.preventDefault();
if (this.state.selectedTargets.length) {
this.setState({confirmTargets: this.state.selectedTargets.slice()});
}
}
handleCancelConfirm() {
this.setState({confirmTargets: null, error: null});
}
handleSend() {
// seq must be a JSON string (server struct tag is `json:"seq,string"`), same as favoriteMessages.
const sources = this.props.seqs.map(seq => ({topic: this.props.topicName, seq: String(seq)}));
this.setState({sending: true, error: null});
forwardMessages('separate', sources, this.state.confirmTargets).then(result => {
const failed = (result && result.failed) || [];
if (failed.length) {
this.setState({sending: false, error: `${failed.length} 条转发失败,请重试`});
return;
}
this.props.hide(true);
}).catch(err => {
this.setState({sending: false, error: err.message || '转发失败'});
});
}
render() {
if (this.state.confirmTargets) {
const targets = this.state.confirmTargets.map(uid => this.props.tinode.getTopic(uid) || {name: uid, public: null});
return (
<div className="alert-container">
<div className="forward-dialog batch-forward-confirm">
<div className="title">发送给</div>
<ul className="contact-box">
{targets.map(t => <li className="batch-forward-target" key={t.name}>
<span>{Tinode.isSelfTopicName(t.name) ? '已保存的消息' : topicDisplayName(t.name, t.public, t.name)}</span>
<i className="material-icons">chevron_right</i>
</li>)}
</ul>
<div className="batch-forward-comment">{'[逐条转发]共' + this.props.seqs.length + '条消息'}</div>
{this.state.error ? <div className="auth-field-error">{this.state.error}</div> : null}
<div className="batch-forward-actions">
<button type="button" onClick={this.handleCancelConfirm} disabled={this.state.sending}>取消</button>
<button type="button" className="primary" onClick={this.handleSend} disabled={this.state.sending}>
{this.state.sending ? '发送中…' : '发送'}
</button>
</div>
</div>
</div>
);
}
let contacts = this.state.query != null ? this.props.searchResults : this.props.contacts;
contacts = contacts.filter((c) => (c.name != this.props.topicSelected) && c.acs.isJoiner() && c.acs.isWriter());
return (
<div className="alert-container">
<div className="forward-dialog">
<div className="title with-control">
<div>转发</div>
<div>
<a href="#" onClick={this.handleToggleMultiSelect}>{this.state.multiSelect ? '' : '多选'}</a>
{this.state.multiSelect ?
<a href="#" onClick={this.handleConfirmTargets}
className={this.state.selectedTargets.length ? '' : 'disabled'}>确定</a> : null}
<a href="#" onClick={this.handleClose}><i className="material-icons">close</i></a>
</div>
</div>
<SearchContacts placeholder="搜索联系人" onSearchContacts={this.handleSearchContacts} />
<ContactList
tinode={this.props.tinode}
contacts={contacts}
myUserId={this.props.myUserId}
emptyListMessage="搜索返回任何结果"
showOnline={false}
showUnread={false}
showContextMenu={false}
topicSelected={this.state.multiSelect ? this.state.selectedTargets : null}
onTopicSelected={this.handleContactSelected} />
</div>
</div>
);
}
}
+35 -26
View File
@@ -116,35 +116,44 @@ export default class CallIncoming extends React.Component {
} }
render() { render() {
const isAudioOnly = !!this.props.audioOnly;
const ringing = this.props.callState == CALL_STATE_INCOMING_RECEIVED;
const surfaceClass = isAudioOnly ? 'call-audio-surface' : 'call-video-surface';
const photoStyle = this.state.avatar ? {'--incoming-call-photo': `url(${this.state.avatar})`} : null;
return ( return (
<div className="alert-container"> <div className={`call-fullscreen incoming-call-overlay ${surfaceClass} incoming`}>
<div className="incoming-call"> <div className="call-ambient" />
<div className="caller-card incoming pulse"> {this.state.avatar &&
<div className="avatar-box"> <div className="incoming-call-photo-blur" style={photoStyle} />}
<LetterTile <div className="call-peer-card">
authorizeURL={this.props.tinode.authorizeURL} <div className="avatar-box call-product-avatar">
avatar={this.state.avatar || true} <LetterTile
topic={this.props.topic} authorizeURL={this.props.tinode.authorizeURL}
title={this.state.fullName} /> avatar={this.state.avatar || true}
</div> topic={this.props.topic}
<div className="caller-name">{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)} title={this.state.fullName} />
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></div>
</div>
<div id="controls">
{this.props.callState == CALL_STATE_INCOMING_RECEIVED ?
<>
<button className="danger" onClick={this.handleRejectCall}>
<i className="material-icons">call_end</i>
</button>
<button className="positive" onClick={this.handleAcceptCall}>
<i className="material-icons">call</i>
</button>
</>
:
null
}
</div> </div>
<h1>{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></h1>
<p>{isAudioOnly ? '对方邀请你语音通话...' : '对方邀请你视频通话...'}</p>
{ringing && <span className="call-ringing-dots"><i /><i /><i /></span>}
</div> </div>
<footer className="call-incoming-actions">
{ringing ?
<>
<button type="button" className="call-control danger" onClick={this.handleRejectCall}>
<span><i className="material-icons">call_end</i></span>
<small>拒绝</small>
</button>
<button type="button" className="call-control" onClick={this.handleAcceptCall}>
<span><i className="material-icons">{isAudioOnly ? 'call' : 'videocam'}</i></span>
<small>接听</small>
</button>
</>
:
null
}
</footer>
</div> </div>
); );
} }
+196 -37
View File
@@ -17,6 +17,14 @@ const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted'; const VIDEO_MUTED_EVENT = 'video:muted';
const VIDEO_UNMUTED_EVENT = 'video:unmuted'; const VIDEO_UNMUTED_EVENT = 'video:unmuted';
function formatElapsedTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const pad = n => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
const messages = defineMessages({ const messages = defineMessages({
already_in_call: { already_in_call: {
id: 'already_in_call', id: 'already_in_call',
@@ -44,7 +52,13 @@ class CallPanel extends React.PureComponent {
videoToggleInProgress: false, videoToggleInProgress: false,
// Indicates if the remote peer has informed us that their camera is on. // Indicates if the remote peer has informed us that their camera is on.
remoteVideoLive: false, remoteVideoLive: false,
// Seconds elapsed since the call was connected (remote media received).
elapsedSeconds: 0,
// Whether remote audio is routed to the loud speaker output (best-effort, see handleToggleSpeakerClick).
speakerOn: true,
}; };
this.callStartedAt = null;
this.elapsedTimerId = null;
this.localStreamConstraints = { this.localStreamConstraints = {
audio: true, audio: true,
@@ -89,6 +103,11 @@ class CallPanel extends React.PureComponent {
this.handleCloseClick = this.handleCloseClick.bind(this); this.handleCloseClick = this.handleCloseClick.bind(this);
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this); this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
this.handleToggleMicClick = this.handleToggleMicClick.bind(this); this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
this.handleToggleSpeakerClick = this.handleToggleSpeakerClick.bind(this);
this.handleSwitchCameraClick = this.handleSwitchCameraClick.bind(this);
this.startElapsedTimer = this.startElapsedTimer.bind(this);
this.stopElapsedTimer = this.stopElapsedTimer.bind(this);
this.handleRemoteHangup = this.handleRemoteHangup.bind(this); this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this); this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
@@ -106,24 +125,29 @@ class CallPanel extends React.PureComponent {
this.bounds = {}; this.bounds = {};
this.dragStart = (event) => { this.dragStart = (event) => {
if (!event.target.classList.contains('draggable')) { if (!event.target.closest('.draggable')) {
// Prevent dragging when clicking on buttons or other non-draggable elements. // Prevent dragging when clicking on buttons or other non-draggable elements.
return; return;
} }
if (window.matchMedia('(width<=640px)')) { if (window.matchMedia('(width<=640px)').matches) {
// No dragging on small screens. // No dragging on small screens.
return; return;
} }
if (this.props.minimized && this.containerRef) { if (this.props.minimized && this.containerRef) {
event.stopPropagation(); event.stopPropagation();
this.containerRef.current.setPointerCapture(event.pointerId); const el = this.containerRef.current;
this.containerRef.current.style.cursor = 'grabbing'; el.setPointerCapture(event.pointerId);
this.bounds.left = this.containerRef.current.offsetLeft; el.style.cursor = 'grabbing';
this.bounds.top = this.containerRef.current.offsetTop; this.bounds.left = el.offsetLeft;
this.bounds.width = this.containerRef.current.offsetWidth; this.bounds.top = el.offsetTop;
this.bounds.height = this.containerRef.current.offsetHeight; this.bounds.width = el.offsetWidth;
this.bounds.height = el.offsetHeight;
// Switch from right/auto-left CSS positioning to explicit left/top so drag() can move the element freely.
el.style.right = 'auto';
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const parent = this.containerRef.current.offsetParent; const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth; this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight; this.bounds.maxY = parent.offsetHeight;
} }
@@ -274,6 +298,7 @@ class CallPanel extends React.PureComponent {
CALL_ENDED_SOUND.currentTime = 0; CALL_ENDED_SOUND.currentTime = 0;
RING_SOUND.pause(); RING_SOUND.pause();
RING_SOUND.currentTime = 0; RING_SOUND.currentTime = 0;
this.stopElapsedTimer();
this.stopTracks(this.state.localStream); this.stopTracks(this.state.localStream);
this.stopTracks(this.state.remoteStream); this.stopTracks(this.state.remoteStream);
@@ -480,6 +505,27 @@ class CallPanel extends React.PureComponent {
// Remote video becomes available. // Remote video becomes available.
this.remoteRef.current.srcObject = event.streams[0]; this.remoteRef.current.srcObject = event.streams[0];
this.setState({remoteStream: event.streams[0]}); this.setState({remoteStream: event.streams[0]});
// First remote media (audio or video) means the call is actually connected.
this.startElapsedTimer();
}
startElapsedTimer() {
if (this.elapsedTimerId) {
// Already running.
return;
}
this.callStartedAt = Date.now();
this.elapsedTimerId = setInterval(_ => {
this.setState({elapsedSeconds: Math.floor((Date.now() - this.callStartedAt) / 1000)});
}, 1000);
}
stopElapsedTimer() {
if (this.elapsedTimerId) {
clearInterval(this.elapsedTimerId);
this.elapsedTimerId = null;
}
this.callStartedAt = null;
} }
handleGetUserMediaError(e) { handleGetUserMediaError(e) {
@@ -573,47 +619,104 @@ class CallPanel extends React.PureComponent {
this.props.onHangup(this.props.topic, this.props.seq); this.props.onHangup(this.props.topic, this.props.seq);
} }
// Ends video track and turns off the camera. // Ends video track and turns off the camera. Also usable before the peer connection
// exists yet (e.g. caller previewing/toggling their camera while waiting for pickup) --
// in that case there is no peer to notify, so the data channel send is skipped.
muteVideo() { muteVideo() {
if (!this.state.pc || !this.state.dataChannel) {
return;
}
const stream = this.state.localStream; const stream = this.state.localStream;
const t = stream.getVideoTracks()[0]; const t = stream.getVideoTracks()[0];
if (!t) {
this.setState({videoToggleInProgress: false});
return;
}
t.enabled = false; t.enabled = false;
t.stop(); t.stop();
stream.removeTrack(t); stream.removeTrack(t);
this.state.dataChannel.send(VIDEO_MUTED_EVENT);
this.sendOnDataChannel(VIDEO_MUTED_EVENT);
this.setState({videoToggleInProgress: false}); this.setState({videoToggleInProgress: false});
} }
unmuteVideo() { // Swaps in a fresh local video track (new camera, or re-enabling after mute), hot-swapping
if (!this.state.pc || !this.state.dataChannel) { // the peer connection's sender if one exists, or adding one if the call never negotiated
return; // video (e.g. started audio-only, or was muted before the peer connection was created --
// handleVideoCallAccepted/handleVideoOfferMsg will pick up whatever is in localStream when
// they run). Returns a promise that resolves once the local preview is updated.
replaceLocalVideoTrack(track) {
this.localRef.current.srcObject = null;
// Drop any existing video track (e.g. the disabled placeholder from an audio-only
// call, or the camera being switched away from) before adding the fresh one.
this.state.localStream.getVideoTracks().forEach(old => this.state.localStream.removeTrack(old));
this.state.localStream.addTrack(track);
let result = Promise.resolve();
if (this.state.pc) {
const sender = this.state.pc.getSenders().find(s => s.track && s.track.kind == 'video');
if (sender) {
result = sender.replaceTrack(track);
} else {
// No video sender was ever negotiated -- add one now; triggers renegotiation.
this.state.pc.addTrack(track, this.state.localStream);
}
} }
return result.then(_ => { this.localRef.current.srcObject = this.state.localStream; });
}
// Turns the camera back on. Works both before the peer connection exists (the fresh
// track is picked up later by handleVideoCallAccepted/handleVideoOfferMsg when they add
// all current local tracks to the new pc) and mid-call (hot-swaps or adds a sender).
unmuteVideo() {
navigator.mediaDevices.getUserMedia({ video: true }) navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => { .then(stream => {
// Will extract video track from stream and throw stream away,
// and replace video track in the media sender.
this.localRef.current.srcObject = null;
const sender = this.state.pc.getSenders().find(s => s.track.kind == 'video');
const track = stream.getVideoTracks()[0]; const track = stream.getVideoTracks()[0];
// Remote track from new stream.
stream.removeTrack(track); stream.removeTrack(track);
// Add this track to the existing local stream. return this.replaceLocalVideoTrack(track);
this.state.localStream.addTrack(track);
return sender.replaceTrack(track);
})
.then(_ => {
this.localRef.current.srcObject = this.state.localStream;
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
}) })
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
.catch(err => this.handleGetUserMediaError(err)) .catch(err => this.handleGetUserMediaError(err))
.finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off). .finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
} }
// Data channel exists as soon as the peer connection is created, but isn't actually usable
// until the underlying handshake completes -- sending before then throws InvalidStateError.
sendOnDataChannel(message) {
if (this.state.dataChannel && this.state.dataChannel.readyState == 'open') {
this.state.dataChannel.send(message);
}
}
// Cycles to the next available camera (front/back on mobile, or the next webcam on desktop).
handleSwitchCameraClick() {
if (this.state.videoToggleInProgress || this.state.audioOnly) {
return;
}
const currentTrack = this.state.localStream.getVideoTracks()[0];
const currentDeviceId = currentTrack && currentTrack.getSettings && currentTrack.getSettings().deviceId;
this.setState({videoToggleInProgress: true}, _ => {
navigator.mediaDevices.enumerateDevices()
.then(devices => {
const cameras = devices.filter(d => d.kind == 'videoinput');
if (cameras.length < 2) {
// Nothing to switch to.
return null;
}
const idx = cameras.findIndex(d => d.deviceId == currentDeviceId);
const next = cameras[(idx + 1) % cameras.length];
return navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: next.deviceId } } });
})
.then(stream => {
if (!stream) {
return null;
}
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
return this.replaceLocalVideoTrack(track);
})
.catch(err => this.handleGetUserMediaError(err))
.finally(_ => { this.setState({videoToggleInProgress: false}); });
});
}
handleToggleCameraClick() { handleToggleCameraClick() {
if (this.state.videoToggleInProgress) { if (this.state.videoToggleInProgress) {
// Toggle currently in progress. // Toggle currently in progress.
@@ -638,14 +741,44 @@ class CallPanel extends React.PureComponent {
this.forceUpdate(); this.forceUpdate();
} }
static speakerSwitchingSupported() {
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype;
}
handleToggleSpeakerClick() {
if (!CallPanel.speakerSwitchingSupported() || !this.remoteRef.current) {
return;
}
const nextOn = !this.state.speakerOn;
navigator.mediaDevices.enumerateDevices()
.then(devices => {
const outputs = devices.filter(d => d.kind == 'audiooutput');
// Best-effort: look for a device labeled as the loud speaker vs. the earpiece/default output.
// Device labels are not standardized, so fall back to the system default sink when no match is found.
const match = nextOn ?
outputs.find(d => /speaker/i.test(d.label)) :
outputs.find(d => /earpiece|receiver/i.test(d.label));
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
})
.then(_ => this.setState({speakerOn: nextOn}))
.catch(err => console.error('Failed to switch audio output device', err));
}
render() { render() {
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks(); const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks(); const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks();
const disabled = !this.state.pc || !this.state.dataChannel || !(audioTracks && audioTracks[0]); // Mic/camera/speaker only need a local stream to exist -- toggling doesn't require a
const audioIcon = audioTracks && audioTracks[0] && audioTracks[0].enabled ? 'mic' : 'mic_off'; // peer connection (e.g. caller previewing their camera while waiting for pickup).
const videoIcon = videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live' ? 'videocam' : 'videocam_off'; const disabled = !(audioTracks && audioTracks[0]);
const micOn = !!(audioTracks && audioTracks[0] && audioTracks[0].enabled);
const audioIcon = micOn ? 'mic' : 'mic_off';
const videoOn = !!(videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live');
const videoIcon = videoOn ? 'videocam' : 'videocam_off';
const speakerSupported = CallPanel.speakerSwitchingSupported();
const speakerIcon = this.state.speakerOn ? 'volume_up' : 'volume_off';
const peerTitle = truncateString(this.props.title, MAX_PEER_TITLE_LENGTH); const peerTitle = truncateString(this.props.title, MAX_PEER_TITLE_LENGTH);
const pulseAnimation = this.state.waitingForPeer ? ' pulse' : ''; const pulseAnimation = this.state.waitingForPeer ? ' pulse' : '';
const connected = !!this.state.remoteStream;
let remoteActive = false; let remoteActive = false;
if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) { if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) {
@@ -659,9 +792,26 @@ class CallPanel extends React.PureComponent {
const minimizedClass = this.props.minimized ? 'minimized' : null; const minimizedClass = this.props.minimized ? 'minimized' : null;
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit'; const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
if (this.props.minimized && this.state.audioOnly) {
// Voice calls minimize to a small pill (icon + timer), not the video-shaped box.
return (
<div id="video-container" className="minimized audio-minimized" ref={this.containerRef}>
<div className="call-pill draggable">
<i className="material-icons">call</i>
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
<button className="full-screen" onClick={this.props.onToggleMinimize}>
<i className="material-icons">{fullScreen}</i>
</button>
</div>
</div>
);
}
return ( return (
<div id="video-container" className={minimizedClass} ref={this.containerRef}> <div id="video-container" className={minimizedClass} ref={this.containerRef}>
<div id="video-container-panel"> {connected &&
<div className="call-duration">{formatElapsedTime(this.state.elapsedSeconds)}</div>}
<div id="video-container-panel" className="draggable">
<div className="call-party self" disabled={this.state.audioOnly || this.props.minimized}> <div className="call-party self" disabled={this.state.audioOnly || this.props.minimized}>
<video ref={this.localRef} autoPlay muted playsInline /> <video ref={this.localRef} autoPlay muted playsInline />
<div className="caller-name inactive"> <div className="caller-name inactive">
@@ -698,12 +848,21 @@ class CallPanel extends React.PureComponent {
<button className="danger" onClick={this.handleCloseClick}> <button className="danger" onClick={this.handleCloseClick}>
<i className="material-icons">call_end</i> <i className="material-icons">call_end</i>
</button> </button>
<button className="secondary" onClick={this.handleToggleCameraClick} disabled={disabled}> <button className={`secondary${videoOn ? '' : ' off'}`} onClick={this.handleToggleCameraClick} disabled={disabled}>
<i className="material-icons">{videoIcon}</i> <i className="material-icons">{videoIcon}</i>
</button> </button>
<button className="secondary" onClick={this.handleToggleMicClick} disabled={disabled}> <button className={`secondary${micOn ? '' : ' off'}`} onClick={this.handleToggleMicClick} disabled={disabled}>
<i className="material-icons">{audioIcon}</i> <i className="material-icons">{audioIcon}</i>
</button> </button>
{speakerSupported &&
<button className={`secondary${this.state.speakerOn ? '' : ' off'}`}
onClick={this.handleToggleSpeakerClick} disabled={disabled}>
<i className="material-icons">{speakerIcon}</i>
</button>}
{!this.state.audioOnly &&
<button className="secondary" onClick={this.handleSwitchCameraClick} disabled={disabled || !videoOn}>
<i className="material-icons">flip_camera_android</i>
</button>}
</div> </div>
</div> </div>
); );
+43 -1
View File
@@ -188,15 +188,47 @@ class BaseChatMessage extends React.PureComponent {
} }
} }
// Reason caption shown under a failed outgoing message in a 1:1 chat, based on the real
// relationship state (see docs/images/个人对话—拉黑状态/已读状态.png and .../个人对话—删除状态.png).
// Returns null for relationships that don't explain a send failure (friend, pending, unknown) so
// a plain network failure doesn't get a misleading relationship-based caption.
renderFailReason() {
if (this.props.response ||
(this.props.received != Tinode.MESSAGE_STATUS_FAILED && this.props.received != Tinode.MESSAGE_STATUS_FATAL)) {
return null;
}
if (this.props.relationship == 'blocked_by_peer' || this.props.relationship == 'blocked_by_me') {
return <div className="message-fail-reason">消息已发送,但被对方拒收了</div>;
}
if (this.props.relationship == 'none') {
return <div className="message-fail-reason">你还不是他(她)朋友。<a href="#"
onClick={this.props.onSendFriendVerification}>发送朋友验证</a></div>;
}
return null;
}
render() { render() {
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'); const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble'; const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.userAvatar || true; const avatar = this.props.userAvatar || true;
const isFailed = !this.props.response &&
(this.props.received == Tinode.MESSAGE_STATUS_FAILED || this.props.received == Tinode.MESSAGE_STATUS_FATAL);
const failReason = this.renderFailReason();
let textSizeClass = 'message-content'; let textSizeClass = 'message-content';
const fullDisplay = (this.props.isGroup && this.props.response && const fullDisplay = (this.props.isGroup && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last')); (this.props.sequence == 'single' || this.props.sequence == 'last'));
let content = this.props.content; let content = this.props.content;
// Server-generated conversation notices (currently just `friend_added`, sent once when two
// users become friends -- see server's publishIMConversationSystemNotice) carry a custom JSON
// shape ({template, text, actors, ...}), not Drafty and not a plain string, so without this it
// falls through to the generic "invalid content" placeholder below. Reduce it to its `text`
// upfront so the existing plain-string path renders it as an ordinary bubble -- these are
// meant to read like a normal message ("你们已成为好友,现在可以开始聊天了。"), not a special banner.
if (this.props.head && this.props.head['x-im-type'] === 'system_notice' &&
content && typeof content == 'object' && typeof content.text == 'string') {
content = content.text;
}
const attachments = []; const attachments = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) { if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, (att, i) => { Drafty.attachments(content, (att, i) => {
@@ -234,6 +266,11 @@ class BaseChatMessage extends React.PureComponent {
return ( return (
<li ref={this.props.innerRef} className={sideClass}> <li ref={this.props.innerRef} className={sideClass}>
{this.props.selectMode ?
<i className={'material-icons select-checkbox' + (this.props.selected ? ' blue' : '')}
onClick={() => this.props.onToggleSelect(this.props.seq)}>
{this.props.selected ? 'check_circle' : 'radio_button_unchecked'}
</i> : null}
{this.props.isGroup && this.props.response ? {this.props.isGroup && this.props.response ?
<div className="avatar-box"> <div className="avatar-box">
{fullDisplay ? {fullDisplay ?
@@ -245,6 +282,8 @@ class BaseChatMessage extends React.PureComponent {
null} null}
</div> : </div> :
null} null}
{isFailed && failReason ?
<i className="material-icons red message-fail-icon">error</i> : null}
<div> <div>
<div className={bubbleClass}> <div className={bubbleClass}>
<div className="content-meta"> <div className="content-meta">
@@ -259,7 +298,7 @@ class BaseChatMessage extends React.PureComponent {
received={this.props.received} /> received={this.props.received} />
: null} : null}
</div> </div>
{this.props.showContextMenu ? {this.props.showContextMenu && !this.props.selectMode ?
<span className="menuTrigger"> <span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}> <a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i> <i className="material-icons">expand_more</i>
@@ -267,12 +306,15 @@ class BaseChatMessage extends React.PureComponent {
</span> : null </span> : null
} }
</div> </div>
{failReason}
{fullDisplay ? {fullDisplay ?
<div className="author"> <div className="author">
{this.props.userName || {this.props.userName ||
<i><FormattedMessage id="user_not_found" defaultMessage="Not found" <i><FormattedMessage id="user_not_found" defaultMessage="Not found"
description="In place of a user's full name when the user is not found." /></i> description="In place of a user's full name when the user is not found." /></i>
} }
{this.props.senderRole == 'owner' ? <em className="role-badge owner">群主</em> :
this.props.senderRole == 'admin' ? <em className="role-badge admin">管理员</em> : null}
</div> </div>
: null : null
} }
+7 -5
View File
@@ -9,6 +9,7 @@ import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx'; import ContactAction from './contact-action.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { MESSAGE_PREVIEW_LENGTH } from '../config.js'; import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
@@ -35,8 +36,8 @@ class ContactList extends React.PureComponent {
let contactsCount = 0; let contactsCount = 0;
if (this.props.showSelfTopic) { if (this.props.showSelfTopic) {
const selected = showCheckmark ? const selected = showCheckmark ?
(this.props.topicSelected.indexOf('slf') > -1) : this.props.topicSelected.some(Tinode.isSelfTopicName) :
(this.props.topicSelected === 'slf'); Tinode.isSelfTopicName(this.props.topicSelected);
contactNodes.push( contactNodes.push(
<Contact <Contact
tinode={this.props.tinode} tinode={this.props.tinode}
@@ -78,8 +79,9 @@ class ContactList extends React.PureComponent {
if (c.private && c.private.comment) { if (c.private && c.private.comment) {
filterOn.push(('' + c.private.comment).toLowerCase()); filterOn.push(('' + c.private.comment).toLowerCase());
} }
if (c.public && c.public.fn) { const displayName = topicDisplayName(key, c.public, null);
filterOn.push(('' + c.public.fn).toLowerCase()); if (displayName) {
filterOn.push(('' + displayName).toLowerCase());
} }
if (!this.props.filterFunc(this.props.filter, filterOn)) { if (!this.props.filterFunc(this.props.filter, filterOn)) {
return; return;
@@ -124,7 +126,7 @@ class ContactList extends React.PureComponent {
contactNodes.push( contactNodes.push(
<Contact <Contact
tinode={this.props.tinode} tinode={this.props.tinode}
title={c.public ? c.public.fn : null} title={c.public ? topicDisplayName(key, c.public, null) : null}
avatar={makeImageUrl(c.public ? c.public.photo : null)} avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment} comment={comment}
preview={preview} preview={preview}
+113
View File
@@ -1,8 +1,24 @@
// Context Menu: popup/dropdown menu. // Context Menu: popup/dropdown menu.
import React from 'react'; import React from 'react';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import { REM_SIZE } from '../config.js'; import { REM_SIZE } from '../config.js';
import { favoriteMessages, getFavorites, deleteFavorite, recallMessage } from '../lib/im-api.js';
// Session-local record of which (topic, seq) pairs are already favorited, and the favorite_id once
// resolved. POST /favorites/batch's response only reports counts (created_count/existing_count/
// failed), never the created id, so "取消收藏" (which needs a favorite_id, not a topic/seq) has
// nothing to delete until a follow-up list lookup resolves it -- cached here so that only has to
// happen once per message rather than on every menu open. A fresh ContextMenu instance is
// constructed each time the menu opens (tinode-web.jsx conditionally mounts it), so reading this at
// construction time is enough to pick the right title/action without any extra plumbing.
const favoriteState = new Map();
function favoriteKey(topicName, seq) { return topicName + ':' + seq; }
function findFavorite(topicName, seq) {
return getFavorites({type: 'all'}).then(data => (data.items || [])
.find(it => it.source && it.source.topic === topicName && String(it.source.seq) === String(seq)));
}
const messages = defineMessages({ const messages = defineMessages({
info: { info: {
@@ -226,6 +242,33 @@ class ContextMenu extends React.Component {
title: formatMessage(messages.forward), title: formatMessage(messages.forward),
handler: _ => {} /* the action is taken directly in tinode-web */ handler: _ => {} /* the action is taken directly in tinode-web */
}, },
'menu_item_copy': {
id: 'menu_item_copy',
title: '复制',
handler: (params, errorHandler) => {
return this.copyMessageText(params, errorHandler);
}
},
'menu_item_favorite': {
id: 'menu_item_favorite',
title: favoriteState.has(favoriteKey(props.params && props.params.topicName, props.params && props.params.seq)) ? '取消收藏' : '收藏',
handler: (params, errorHandler) => {
return favoriteState.has(favoriteKey(params.topicName, params.seq)) ?
this.removeFavorite(params, errorHandler) : this.addFavorite(params, errorHandler);
}
},
'menu_item_recall': {
id: 'menu_item_recall',
title: '撤回',
handler: (params, errorHandler) => {
return this.recallOwnMessage(params, errorHandler);
}
},
'menu_item_select': {
id: 'menu_item_select',
title: '多选',
handler: _ => {} /* the action is taken directly in tinode-web, like forward */
},
'menu_item_edit': { 'menu_item_edit': {
id: 'menu_item_edit', id: 'menu_item_edit',
title: formatMessage(messages.edit), title: formatMessage(messages.edit),
@@ -487,6 +530,76 @@ class ContextMenu extends React.Component {
}); });
} }
// Copies the message's plain-text content to the clipboard.
copyMessageText(params, errorHandler) {
const text = typeof params.content == 'string' ? params.content : Drafty.toPlainText(params.content);
return navigator.clipboard.writeText(text || '').catch(err => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
addFavorite(params, errorHandler) {
return favoriteMessages([{topic: params.topicName, seq: params.seq}]).then(result => {
// The request itself resolves 200 even when the specific message couldn't be favorited --
// real per-item outcomes are only in this body (created_count/existing_count/failed), same
// "batch call succeeds at the transport level, item can still fail" shape already handled for
// forward/batch-delete elsewhere in this app. Left unchecked, a failed item here (e.g. the
// backend's message-mirror table hasn't indexed this message yet) shows no error at all --
// indistinguishable from success -- and silently never appears in 我的收藏.
const failedItem = result && result.failed && result.failed[0];
if (failedItem) {
if (errorHandler) {
errorHandler(failedItem.reason === 'NOT_FOUND' ? '收藏失败,该消息暂无法收藏,请稍后重试' : '收藏失败', 'err');
}
return;
}
const key = favoriteKey(params.topicName, params.seq);
favoriteState.set(key, true);
// Resolve the real favorite_id in the background so a later "取消收藏" has it ready -- see the
// module-level comment on favoriteState for why this needs a separate lookup at all.
findFavorite(params.topicName, params.seq).then(match => {
if (match) favoriteState.set(key, match.favorite_id);
}).catch(() => {});
}).catch(err => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
removeFavorite(params, errorHandler) {
const key = favoriteKey(params.topicName, params.seq);
const cached = favoriteState.get(key);
const finish = favoriteId => deleteFavorite(favoriteId).then(() => favoriteState.delete(key));
// A real favorite_id may not have resolved yet if this is opened right after addFavorite's own
// background lookup started -- fall back to looking it up now rather than failing silently.
const ready = (cached && cached !== true) ? Promise.resolve(cached) : findFavorite(params.topicName, params.seq).then(match => match && match.favorite_id);
return ready.then(favoriteId => {
if (!favoriteId) { if (errorHandler) errorHandler('未找到对应收藏', 'err'); return; }
return finish(favoriteId);
}).catch(err => {
if (errorHandler) {
errorHandler(err.message || '取消收藏失败', 'err');
}
});
}
// Recall (Ch.4): a dedicated backend action distinct from delete -- server-enforced short time
// window, permission check, and a 'message.recalled' business event for the other party. It does
// not touch Tinode's own message store, so once the server accepts it we also hard-delete locally
// (same call the plain 'Delete for All' item uses) to reflect it in this client's live view.
recallOwnMessage(params, errorHandler) {
return recallMessage(params.topicName, params.seq).then(_ => {
return this.deleteMessages(false, true, params, errorHandler);
}).catch(err => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Retries sending failed message. // Retries sending failed message.
retryMessage(params, errorHandler) { retryMessage(params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName); const topic = this.props.tinode.getTopic(params.topicName);
+4 -1
View File
@@ -9,6 +9,7 @@ import TagManager from './tag-manager.jsx';
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH, import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH,
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js'; MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js'; import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { createChatUploader } from '../lib/im-api.js';
import { truncateString } from '../lib/strformat.js'; import { truncateString } from '../lib/strformat.js';
export default class NewTopicGroup extends React.PureComponent { export default class NewTopicGroup extends React.PureComponent {
@@ -69,7 +70,9 @@ export default class NewTopicGroup extends React.PureComponent {
let {mime, blob} = image; let {mime, blob} = image;
if (blob.size > MAX_AVATAR_BYTES) { if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); // Same wrong-header bug messages-view.jsx's chat uploads had -- createChatUploader() is the
// fixed-headers drop-in (see im-api.js for the full explanation).
const uploader = createChatUploader(this.props.tinode);
uploader.upload(blob) uploader.upload(blob)
.then(url => this.setState({imageUrl: url})) .then(url => this.setState({imageUrl: url}))
.catch(err => this.props.onError(err.message, 'err')); .catch(err => this.props.onError(err.message, 'err'));
+183
View File
@@ -0,0 +1,183 @@
// QR scanning ("扫一扫", entry points in message-center-view.jsx's quick menu and
// contact-management-view.jsx's AddFriend page). Both entries used to just toast "not implemented
// yet" -- this is the real camera capture + decode implementation, wired to `resolveQRCode` /
// `createGroupJoinRequest` (im-api.js), which already existed but had zero call sites.
//
// No reference screenshot covers the scanning viewfinder itself -- docs/images/ only has the
// generate-a-code screens (二维码.png, 群二维码页面.png). The full-screen dark camera view with a
// bordered square cutout below follows this app's existing full-screen-overlay language (see
// call-panel.jsx/call-incoming.jsx) rather than a specific design, since there is nothing to match.
import React from 'react';
import jsQR from 'jsqr';
import { getFileUrl } from '../lib/im-api.js';
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
// Decoding a full-resolution camera frame (often 1080p+) with jsQR on every tick is unnecessarily
// expensive -- a QR code large enough to matter in frame still decodes fine downscaled, and it
// keeps the scan loop smooth on lower-end phones. 'dontInvert' skips jsQR's extra dark/light
// inversion pass, which normal on-screen/printed codes (dark modules on a light quiet zone) never
// need -- also just a perf win for a loop that runs several times a second.
const SCAN_INTERVAL_MS = 200;
const MAX_SCAN_EDGE = 480;
const JSQR_OPTIONS = {inversionAttempts: 'dontInvert'};
export default class QrScanner extends React.Component {
constructor(props) {
super(props);
this.state = {status: 'starting', errorText: ''};
this.videoRef = React.createRef();
this.canvas = document.createElement('canvas');
this.stream = null;
this.timer = null;
this.detected = false;
this.start = this.start.bind(this);
this.stopStream = this.stopStream.bind(this);
this.tick = this.tick.bind(this);
}
componentDidMount() {
this.start();
}
componentWillUnmount() {
this.stopStream();
}
start() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.setState({status: 'error', errorText: '当前浏览器不支持摄像头访问'});
return;
}
this.detected = false;
this.setState({status: 'starting', errorText: ''});
// `facingMode` here is a hint, not `{exact: 'environment'}` -- a desktop/laptop with only a
// front camera still gets a stream instead of failing with OverconstrainedError.
navigator.mediaDevices.getUserMedia({video: {facingMode: 'environment'}, audio: false}).then(stream => {
this.stream = stream;
const video = this.videoRef.current;
if (!video) {
// Unmounted while the permission prompt was pending.
stream.getTracks().forEach(track => track.stop());
return;
}
video.srcObject = stream;
video.play().catch(() => {});
this.setState({status: 'scanning'});
this.timer = window.setInterval(this.tick, SCAN_INTERVAL_MS);
}).catch(err => {
const name = err && err.name;
const text = name === 'NotAllowedError' ? '未获得摄像头权限,请在浏览器设置中允许访问摄像头' :
name === 'NotFoundError' ? '未检测到可用摄像头' :
name === 'NotReadableError' ? '摄像头正被其他程序占用' : '摄像头启动失败,请重试';
this.setState({status: 'error', errorText: text});
});
}
stopStream() {
if (this.timer) {
window.clearInterval(this.timer);
this.timer = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
}
tick() {
if (this.detected) {
return;
}
const video = this.videoRef.current;
if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) {
return;
}
const vw = video.videoWidth;
const vh = video.videoHeight;
if (!vw || !vh) {
return;
}
const scale = Math.min(1, MAX_SCAN_EDGE / Math.max(vw, vh));
const w = Math.round(vw * scale);
const h = Math.round(vh * scale);
this.canvas.width = w;
this.canvas.height = h;
const ctx = this.canvas.getContext('2d', {willReadFrequently: true});
ctx.drawImage(video, 0, 0, w, h);
let frame;
try {
frame = ctx.getImageData(0, 0, w, h);
} catch (err) {
// A transient decode error (e.g. a tainted canvas mid-permission-change) -- try again on the
// next tick rather than surfacing an error the user can't act on.
return;
}
const code = jsQR(frame.data, w, h, JSQR_OPTIONS);
if (code && code.data) {
this.detected = true;
this.setState({status: 'resolving'});
this.stopStream();
this.props.onDetect(code.data);
}
}
render() {
const {status, errorText} = this.state;
return <div className="qr-scan-view">
<video ref={this.videoRef} className="qr-scan-video" muted playsInline autoPlay />
<div className="qr-scan-mask"><div className="qr-scan-frame">
<i className="qr-scan-corner qr-scan-corner-tl" /><i className="qr-scan-corner qr-scan-corner-tr" />
<i className="qr-scan-corner qr-scan-corner-bl" /><i className="qr-scan-corner qr-scan-corner-br" />
</div></div>
<header className="qr-scan-header">
<button type="button" onClick={this.props.onClose} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>扫一扫</h1><span />
</header>
{status === 'starting' ? <p className="qr-scan-hint">正在启动摄像头…</p> : null}
{status === 'scanning' ? <p className="qr-scan-hint">将二维码放入框内,即可自动扫描</p> : null}
{status === 'resolving' ? <p className="qr-scan-hint">识别中…</p> : null}
{status === 'error' ? <div className="qr-scan-error">
<Icon>error_outline</Icon><p>{errorText}</p>
<button type="button" onClick={this.start}>重试</button>
</div> : null}
</div>;
}
}
// Shown after a "join_group" QR resolves to a group the scanner isn't a member of yet
// (resolveQRCode's `status: "can_request"` -- see server/im_http.go's serveIMQRCodesResolve).
// Joining always goes through the same pending-request/admin-approval pipeline as Ch.6's
// JoinRequests, regardless of the group's `join_approval` governance toggle (CreateJoinRequest
// never auto-accepts) -- so this never claims immediate membership, only that a request was sent.
export function GroupJoinPreview({group, onSubmit, onCancel}) {
const [message, setMessage] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [avatarUrl, setAvatarUrl] = React.useState(null);
React.useEffect(() => {
if (!group.avatar_file_id) {
return undefined;
}
let live = true;
getFileUrl(group.avatar_file_id).then(url => { if (live) setAvatarUrl(url); }).catch(() => {});
return () => { live = false; };
}, [group.avatar_file_id]);
const submit = () => {
setBusy(true);
Promise.resolve(onSubmit(message.trim())).finally(() => setBusy(false));
};
return <div className="qr-join-backdrop"><section className="qr-join-sheet" role="dialog" aria-modal="true">
<span className="qr-join-avatar">{avatarUrl ? <img src={avatarUrl} alt="" /> : <Icon>group</Icon>}</span>
<h2>{group.name || '群聊'}</h2>
<p>{group.member_count || 0} 人</p>
<input value={message} onChange={event => setMessage(event.target.value)} placeholder="验证消息(选填)" maxLength={60} disabled={busy} />
<button type="button" className="primary" disabled={busy} onClick={submit}>{busy ? '提交中…' : '申请加入'}</button>
<button type="button" onClick={onCancel} disabled={busy}>取消</button>
</section></div>;
}
+19 -8
View File
@@ -144,10 +144,13 @@ class SendMessage extends React.PureComponent {
if (this.props.disabled) { if (this.props.disabled) {
return; return;
} }
// onAttachImage/onAttachFile are explicitly null while forwarding a message
// (messages-view.jsx) -- pasting a file in that state used to throw
// "onAttachImage is not a function" instead of just not attaching anything.
// FIXME: handle large files too. // FIXME: handle large files too.
if (filePasted(e, if (filePasted(e,
file => { this.props.onAttachImage(file); }, file => { if (this.props.onAttachImage) { this.props.onAttachImage(file); } },
file => { this.props.onAttachFile(file); }, file => { if (this.props.onAttachFile) { this.props.onAttachFile(file); } },
this.props.onError)) { this.props.onError)) {
// If a file was pasted, don't paste base64 data into input field. // If a file was pasted, don't paste base64 data into input field.
@@ -156,7 +159,7 @@ class SendMessage extends React.PureComponent {
} }
handleAttachImage(e) { handleAttachImage(e) {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0 && this.props.onAttachImage) {
this.props.onAttachImage(e.target.files[0]); this.props.onAttachImage(e.target.files[0]);
} }
// Clear the value so the same file can be uploaded again. // Clear the value so the same file can be uploaded again.
@@ -164,7 +167,7 @@ class SendMessage extends React.PureComponent {
} }
handleAttachFile(e) { handleAttachFile(e) {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0 && this.props.onAttachFile) {
this.props.onAttachFile(e.target.files[0]); this.props.onAttachFile(e.target.files[0]);
} }
// Clear the value so the same file can be uploaded again. // Clear the value so the same file can be uploaded again.
@@ -172,22 +175,30 @@ class SendMessage extends React.PureComponent {
} }
handleDropAttach(files) { handleDropAttach(files) {
if (files && files.length > 0) { if (files && files.length > 0 && this.props.onAttachFile) {
this.props.onAttachFile(files[0]); this.props.onAttachFile(files[0]);
} }
} }
handleAttachAudio(url, preview, duration) { handleAttachAudio(url, preview, duration) {
this.setState({audioRec: false}); this.setState({audioRec: false});
this.props.onAttachAudio(url, preview, duration); if (this.props.onAttachAudio) {
this.props.onAttachAudio(url, preview, duration);
}
} }
handleSend(e) { handleSend(e) {
e.preventDefault(); e.preventDefault();
const message = this.state.message.trim(); const message = this.state.message.trim();
if (message || this.props.acceptBlank || this.props.noInput) { if (message || this.props.acceptBlank || this.props.noInput) {
this.props.onSendMessage(message); const result = this.props.onSendMessage(message);
this.setState({message: ''}); // onSendMessage may return a promise resolving to `false` when a pre-send risk check blocked
// or the user cancelled it -- in that case keep the composer text so nothing typed is lost.
if (result && typeof result.then == 'function') {
result.then(sent => { if (sent !== false) this.setState({message: ''}); });
} else {
this.setState({message: ''});
}
} }
} }
+4 -1
View File
@@ -13,6 +13,7 @@ import TagManager from './tag-manager.jsx';
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH, import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH,
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js'; MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js'; import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { createChatUploader } from '../lib/im-api.js';
import { truncateString } from '../lib/strformat.js'; import { truncateString } from '../lib/strformat.js';
import { arrayEqual, theCard } from '../lib/utils.js'; import { arrayEqual, theCard } from '../lib/utils.js';
@@ -149,7 +150,9 @@ class TopicDescEdit extends React.Component {
let {mime, blob} = image; let {mime, blob} = image;
if (blob.size > MAX_AVATAR_BYTES) { if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link. // Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper(); // Same wrong-header bug messages-view.jsx's chat uploads had -- createChatUploader() is the
// fixed-headers drop-in (see im-api.js for the full explanation).
const uploader = createChatUploader(this.props.tinode);
uploader.upload(blob) uploader.upload(blob)
.then(url => this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url))) .then(url => this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url)))
.catch(err => this.props.onError(err.message, 'err')); .catch(err => this.props.onError(err.message, 'err'));
+44524 -4124
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+188 -29
View File
@@ -31,6 +31,13 @@ CALL_ENDED_SOUND.loop = true;
const DIALING_SOUND = new Audio('audio/dialing.m4a'); const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted'; const VIDEO_MUTED_EVENT = 'video:muted';
const VIDEO_UNMUTED_EVENT = 'video:unmuted'; const VIDEO_UNMUTED_EVENT = 'video:unmuted';
function formatElapsedTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor(totalSeconds % 3600 / 60);
const s = totalSeconds % 60;
const pad = n => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
already_in_call: { already_in_call: {
id: 'already_in_call', id: 'already_in_call',
@@ -51,8 +58,12 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
callInitialSetupComplete: false, callInitialSetupComplete: false,
audioOnly: props.callAudioOnly, audioOnly: props.callAudioOnly,
videoToggleInProgress: false, videoToggleInProgress: false,
remoteVideoLive: false remoteVideoLive: false,
elapsedSeconds: 0,
speakerOn: true
}; };
this.callStartedAt = null;
this.elapsedTimerId = null;
this.localStreamConstraints = { this.localStreamConstraints = {
audio: true, audio: true,
video: !props.callAudioOnly video: !props.callAudioOnly
@@ -86,6 +97,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.handleCloseClick = this.handleCloseClick.bind(this); this.handleCloseClick = this.handleCloseClick.bind(this);
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this); this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
this.handleToggleMicClick = this.handleToggleMicClick.bind(this); this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
this.handleToggleSpeakerClick = this.handleToggleSpeakerClick.bind(this);
this.handleSwitchCameraClick = this.handleSwitchCameraClick.bind(this);
this.startElapsedTimer = this.startElapsedTimer.bind(this);
this.stopElapsedTimer = this.stopElapsedTimer.bind(this);
this.handleRemoteHangup = this.handleRemoteHangup.bind(this); this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this); this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
this.muteVideo = this.muteVideo.bind(this); this.muteVideo = this.muteVideo.bind(this);
@@ -98,21 +113,25 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.handleDataChannelClose = this.handleDataChannelClose.bind(this); this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
this.bounds = {}; this.bounds = {};
this.dragStart = event => { this.dragStart = event => {
if (!event.target.classList.contains('draggable')) { if (!event.target.closest('.draggable')) {
return; return;
} }
if (window.matchMedia('(width<=640px)')) { if (window.matchMedia('(width<=640px)').matches) {
return; return;
} }
if (this.props.minimized && this.containerRef) { if (this.props.minimized && this.containerRef) {
event.stopPropagation(); event.stopPropagation();
this.containerRef.current.setPointerCapture(event.pointerId); const el = this.containerRef.current;
this.containerRef.current.style.cursor = 'grabbing'; el.setPointerCapture(event.pointerId);
this.bounds.left = this.containerRef.current.offsetLeft; el.style.cursor = 'grabbing';
this.bounds.top = this.containerRef.current.offsetTop; this.bounds.left = el.offsetLeft;
this.bounds.width = this.containerRef.current.offsetWidth; this.bounds.top = el.offsetTop;
this.bounds.height = this.containerRef.current.offsetHeight; this.bounds.width = el.offsetWidth;
const parent = this.containerRef.current.offsetParent; this.bounds.height = el.offsetHeight;
el.style.right = 'auto';
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth; this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight; this.bounds.maxY = parent.offsetHeight;
} }
@@ -239,6 +258,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
CALL_ENDED_SOUND.currentTime = 0; CALL_ENDED_SOUND.currentTime = 0;
RING_SOUND.pause(); RING_SOUND.pause();
RING_SOUND.currentTime = 0; RING_SOUND.currentTime = 0;
this.stopElapsedTimer();
this.stopTracks(this.state.localStream); this.stopTracks(this.state.localStream);
this.stopTracks(this.state.remoteStream); this.stopTracks(this.state.remoteStream);
this.disconnectMedia(this.localRef.current); this.disconnectMedia(this.localRef.current);
@@ -431,6 +451,25 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.setState({ this.setState({
remoteStream: event.streams[0] remoteStream: event.streams[0]
}); });
this.startElapsedTimer();
}
startElapsedTimer() {
if (this.elapsedTimerId) {
return;
}
this.callStartedAt = Date.now();
this.elapsedTimerId = setInterval(_ => {
this.setState({
elapsedSeconds: Math.floor((Date.now() - this.callStartedAt) / 1000)
});
}, 1000);
}
stopElapsedTimer() {
if (this.elapsedTimerId) {
clearInterval(this.elapsedTimerId);
this.elapsedTimerId = null;
}
this.callStartedAt = null;
} }
handleGetUserMediaError(e) { handleGetUserMediaError(e) {
console.error("Error opening camera and/or microphone", e); console.error("Error opening camera and/or microphone", e);
@@ -504,41 +543,96 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.props.onHangup(this.props.topic, this.props.seq); this.props.onHangup(this.props.topic, this.props.seq);
} }
muteVideo() { muteVideo() {
if (!this.state.pc || !this.state.dataChannel) {
return;
}
const stream = this.state.localStream; const stream = this.state.localStream;
const t = stream.getVideoTracks()[0]; const t = stream.getVideoTracks()[0];
if (!t) {
this.setState({
videoToggleInProgress: false
});
return;
}
t.enabled = false; t.enabled = false;
t.stop(); t.stop();
stream.removeTrack(t); stream.removeTrack(t);
this.state.dataChannel.send(VIDEO_MUTED_EVENT); this.sendOnDataChannel(VIDEO_MUTED_EVENT);
this.setState({ this.setState({
videoToggleInProgress: false videoToggleInProgress: false
}); });
} }
unmuteVideo() { replaceLocalVideoTrack(track) {
if (!this.state.pc || !this.state.dataChannel) { this.localRef.current.srcObject = null;
return; this.state.localStream.getVideoTracks().forEach(old => this.state.localStream.removeTrack(old));
this.state.localStream.addTrack(track);
let result = Promise.resolve();
if (this.state.pc) {
const sender = this.state.pc.getSenders().find(s => s.track && s.track.kind == 'video');
if (sender) {
result = sender.replaceTrack(track);
} else {
this.state.pc.addTrack(track, this.state.localStream);
}
} }
return result.then(_ => {
this.localRef.current.srcObject = this.state.localStream;
});
}
unmuteVideo() {
navigator.mediaDevices.getUserMedia({ navigator.mediaDevices.getUserMedia({
video: true video: true
}).then(stream => { }).then(stream => {
this.localRef.current.srcObject = null;
const sender = this.state.pc.getSenders().find(s => s.track.kind == 'video');
const track = stream.getVideoTracks()[0]; const track = stream.getVideoTracks()[0];
stream.removeTrack(track); stream.removeTrack(track);
this.state.localStream.addTrack(track); return this.replaceLocalVideoTrack(track);
return sender.replaceTrack(track);
}).then(_ => { }).then(_ => {
this.localRef.current.srcObject = this.state.localStream; this.sendOnDataChannel(VIDEO_UNMUTED_EVENT);
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => { }).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
this.setState({ this.setState({
videoToggleInProgress: false videoToggleInProgress: false
}); });
}); });
} }
sendOnDataChannel(message) {
if (this.state.dataChannel && this.state.dataChannel.readyState == 'open') {
this.state.dataChannel.send(message);
}
}
handleSwitchCameraClick() {
if (this.state.videoToggleInProgress || this.state.audioOnly) {
return;
}
const currentTrack = this.state.localStream.getVideoTracks()[0];
const currentDeviceId = currentTrack && currentTrack.getSettings && currentTrack.getSettings().deviceId;
this.setState({
videoToggleInProgress: true
}, _ => {
navigator.mediaDevices.enumerateDevices().then(devices => {
const cameras = devices.filter(d => d.kind == 'videoinput');
if (cameras.length < 2) {
return null;
}
const idx = cameras.findIndex(d => d.deviceId == currentDeviceId);
const next = cameras[(idx + 1) % cameras.length];
return navigator.mediaDevices.getUserMedia({
video: {
deviceId: {
exact: next.deviceId
}
}
});
}).then(stream => {
if (!stream) {
return null;
}
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
return this.replaceLocalVideoTrack(track);
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
this.setState({
videoToggleInProgress: false
});
});
});
}
handleToggleCameraClick() { handleToggleCameraClick() {
if (this.state.videoToggleInProgress) { if (this.state.videoToggleInProgress) {
return; return;
@@ -563,14 +657,35 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
t.enabled = !t.enabled; t.enabled = !t.enabled;
this.forceUpdate(); this.forceUpdate();
} }
static speakerSwitchingSupported() {
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype;
}
handleToggleSpeakerClick() {
if (!CallPanel.speakerSwitchingSupported() || !this.remoteRef.current) {
return;
}
const nextOn = !this.state.speakerOn;
navigator.mediaDevices.enumerateDevices().then(devices => {
const outputs = devices.filter(d => d.kind == 'audiooutput');
const match = nextOn ? outputs.find(d => /speaker/i.test(d.label)) : outputs.find(d => /earpiece|receiver/i.test(d.label));
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
}).then(_ => this.setState({
speakerOn: nextOn
})).catch(err => console.error('Failed to switch audio output device', err));
}
render() { render() {
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks(); const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks(); const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks();
const disabled = !this.state.pc || !this.state.dataChannel || !(audioTracks && audioTracks[0]); const disabled = !(audioTracks && audioTracks[0]);
const audioIcon = audioTracks && audioTracks[0] && audioTracks[0].enabled ? 'mic' : 'mic_off'; const micOn = !!(audioTracks && audioTracks[0] && audioTracks[0].enabled);
const videoIcon = videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live' ? 'videocam' : 'videocam_off'; const audioIcon = micOn ? 'mic' : 'mic_off';
const videoOn = !!(videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live');
const videoIcon = videoOn ? 'videocam' : 'videocam_off';
const speakerSupported = CallPanel.speakerSwitchingSupported();
const speakerIcon = this.state.speakerOn ? 'volume_up' : 'volume_off';
const peerTitle = (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_5__.truncateString)(this.props.title, _config_js__WEBPACK_IMPORTED_MODULE_3__.MAX_PEER_TITLE_LENGTH); const peerTitle = (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_5__.truncateString)(this.props.title, _config_js__WEBPACK_IMPORTED_MODULE_3__.MAX_PEER_TITLE_LENGTH);
const pulseAnimation = this.state.waitingForPeer ? ' pulse' : ''; const pulseAnimation = this.state.waitingForPeer ? ' pulse' : '';
const connected = !!this.state.remoteStream;
let remoteActive = false; let remoteActive = false;
if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) { if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) {
const rstream = this.remoteRef.current.srcObject; const rstream = this.remoteRef.current.srcObject;
@@ -581,12 +696,40 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
} }
const minimizedClass = this.props.minimized ? 'minimized' : null; const minimizedClass = this.props.minimized ? 'minimized' : null;
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit'; const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
if (this.props.minimized && this.state.audioOnly) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: "minimized audio-minimized",
ref: this.containerRef,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "call"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
className: "call-pill-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "full-screen",
onClick: this.props.onToggleMinimize,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: fullScreen
}, void 0, false)
}, void 0, false)]
}, void 0, true)
}, void 0, false);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", { return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container", id: "video-container",
className: minimizedClass, className: minimizedClass,
ref: this.containerRef, ref: this.containerRef,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", { children: [connected && (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container-panel", id: "video-container-panel",
className: "draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-party self", className: "call-party self",
disabled: this.state.audioOnly || this.props.minimized, disabled: this.state.audioOnly || this.props.minimized,
@@ -654,7 +797,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
children: "call_end" children: "call_end"
}, void 0, false) }, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "secondary", className: `secondary${videoOn ? '' : ' off'}`,
onClick: this.handleToggleCameraClick, onClick: this.handleToggleCameraClick,
disabled: disabled, disabled: disabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", { children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
@@ -662,13 +805,29 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
children: videoIcon children: videoIcon
}, void 0, false) }, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "secondary", className: `secondary${micOn ? '' : ' off'}`,
onClick: this.handleToggleMicClick, onClick: this.handleToggleMicClick,
disabled: disabled, disabled: disabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", { children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons", className: "material-icons",
children: audioIcon children: audioIcon
}, void 0, false) }, void 0, false)
}, void 0, false), speakerSupported && (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: `secondary${this.state.speakerOn ? '' : ' off'}`,
onClick: this.handleToggleSpeakerClick,
disabled: disabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: speakerIcon
}, void 0, false)
}, void 0, false), !this.state.audioOnly && (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "secondary",
onClick: this.handleSwitchCameraClick,
disabled: disabled || !videoOn,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "flip_camera_android"
}, void 0, false)
}, void 0, false)] }, void 0, false)]
}, void 0, true)] }, void 0, true)]
}, void 0, true); }, void 0, true);
File diff suppressed because one or more lines are too long