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
+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.
// 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 {
// Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments.
@@ -24,9 +36,26 @@ export default class HashNavigation {
}
static navigateTo(url) {
navigationCount++;
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) {
let url = path.join('/');
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 { 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.
export function updateFavicon(count) {
const oldIcon = document.getElementById('shortcut-icon');
@@ -215,7 +230,7 @@ export function deliveryMarker(received) {
return { name: 'access_time' }; // watch face
case Tinode.MESSAGE_STATUS_FAILED:
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:
return { name: 'done' }; // checkmark
case Tinode.MESSAGE_STATUS_RECEIVED:
+394 -105
View File
@@ -1,6 +1,11 @@
import React from 'react';
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 PHONE_RE = /^\d{11}$/;
@@ -23,7 +28,7 @@ function AppMark({tenantName}) {
function Header({title, back = 'tenant'}) {
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>
</button>
<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;
}
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 [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = e => {
const submit = async e => {
e.preventDefault();
const value = code.trim();
if (!value || value.length > 64) {
setError(value ? '企业码不能超过 64 个字符' : '请输入企业码');
return;
}
sessionStorage.setItem('im-tenant-code', value);
sessionStorage.setItem('im-tenant-name', value === 'acme' ? '示例企业' : value);
onTenantSelected(value);
navigate('login');
setBusy(true);
setError('');
try {
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">
@@ -88,46 +114,57 @@ function TenantEntry({onTenantSelected}) {
<AppMark />
<Field value={code} onChange={e => { setCode(e.target.value); setError(''); }}
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>
<button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button>
</div>;
}
function Login({tenantName}) {
const [account, setAccount] = React.useState('');
function Login({tenantName, login, loginDisabled, persist, errorText, errorLevel, onLoginRequest, onPersistenceChange, onError}) {
const [account, setAccount] = React.useState(login || '');
const [password, setPassword] = React.useState('');
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 => {
e.preventDefault();
if (!account.trim() || !password || !agreed) return;
if (password === 'error') {
setPassword('');
setError('账号或密码错误');
} else {
setToast('登录成功');
window.setTimeout(() => navigate('messages-home'), 500);
}
if (!account.trim() || !password || !agreed || loginDisabled) return;
onLoginRequest(account.trim(), password);
};
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}>
<AppMark tenantName={tenantName} />
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
<Field value={account} onChange={e => { setAccount(e.target.value); setError(''); }}
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
<Field value={account} onChange={e => setAccount(e.target.value)}
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" />
<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>
</form>
<Agreement checked={agreed} onChange={setAgreed} />
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
@@ -136,7 +173,9 @@ function EnterpriseRegister() {
const [email, setEmail] = React.useState('');
const [file, setFile] = React.useState(null);
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('');
React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]);
@@ -149,31 +188,52 @@ function EnterpriseRegister() {
return;
}
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: ''});
};
const submit = e => {
const submit = async e => {
e.preventDefault();
const next = {};
if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码';
if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱';
setErrors(next);
if (Object.keys(next).length) return;
setSubmitted(true);
sessionStorage.setItem('im-enterprise-application-status', 'received');
setToast('提交成功,我们会尽快联系你');
setSubmitting(true);
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">
<Header title="企业注册" />
<main className="auth-content auth-content-narrow">
<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>3</span><b>提交审核</b></li>
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
</ol>
<form className="auth-form" onSubmit={submit}>
<FormAlert text={errors.form} />
<label className="auth-label">统一社会信用代码</label>
<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} />
@@ -193,187 +253,397 @@ function EnterpriseRegister() {
<Field icon="mail_outline" value={email} onChange={e => setEmail(e.target.value)}
placeholder="请输入企业邮箱" type="email" error={errors.email} />
<p className="auth-help">用于接收审核结果及企业通知</p>
<button className="auth-primary" type="submit" disabled={submitted || creditCode.length !== 18}>{submitted ? '已提交' : '提交资料'}</button>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 1 个工作日完成审核</p>
<button className="auth-primary" type="submit" disabled={submitted || submitting || creditCode.length !== 18}>
{submitted ? '已提交' : submitting ? '提交中…' : '提交资料'}
</button>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 {reviewDays} 个工作日完成审核</p>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</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() {
const [description, setDescription] = React.useState('');
const [contact, setContact] = React.useState('');
const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [toast, setToast] = React.useState('');
const addFiles = e => {
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));
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();
const length = description.trim().length;
if (length < 10 || length > 1000) {
setError('问题描述需为 10 至 1000 个字符');
if (length < 10 || length > 300) {
setError('问题描述需为 10 至 300 个字符');
return;
}
setToast('已收到反馈,我们会尽快处理');
files.forEach(item => URL.revokeObjectURL(item.url));
setDescription(''); setContact(''); setFiles([]);
setSubmitting(true);
try {
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">
<Header title="问题反馈" />
<Header title="意见反馈" />
<main className="auth-content auth-content-narrow">
<form className="auth-form" onSubmit={submit}>
<label className="auth-label">问题描述</label>
<textarea value={description} maxLength="1000" onChange={e => { setDescription(e.target.value); setError(''); }}
placeholder="请详细描述遇到的问题,至少输入10个字" className={error ? 'invalid' : ''} />
<div className="auth-counter">{description.length}/1000</div>
<label className="auth-label">问题反馈</label>
<textarea value={description} maxLength="300" onChange={e => { setDescription(e.target.value); setError(''); }}
placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" className={error ? 'invalid' : ''} />
<div className="auth-counter">{description.length}/300</div>
{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">
{files.map((file, index) => <div className="auth-screenshot" key={file.url}>
<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>
</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>
<label className="auth-label">联系方式 <span>选填</span></label>
<Field value={contact} onChange={e => setContact(e.target.value)} placeholder="手机号或邮箱,便于我们联系您" />
<button className="auth-primary" type="submit" disabled={description.trim().length < 10}>提交反馈</button>
<button className="auth-primary" type="submit" disabled={description.trim().length < 10 || submitting}>{submitting ? '提交中…' : '提交'}</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</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 [busy, setBusy] = React.useState(false);
React.useEffect(() => {
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);
}, [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); }}>
{seconds > 0 ? `${seconds}s` : '获取验证码'}
const click = async () => {
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>;
}
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 [challengeId, setChallengeId] = React.useState('');
const [agreed, setAgreed] = React.useState(false);
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 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();
const next = {};
if (!form.username && !form.phone) next.username = '用户名和手机号至少填写一项';
if (form.username && !USERNAME_RE.test(form.username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
if (form.phone && !PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
if (!form.phone && !INVITE_RE.test(form.invite)) next.invite = '用户名注册需填写 5 位字母邀请码';
if (form.invite && !INVITE_RE.test(form.invite)) next.invite = '邀请码为 5 位大小写字母';
if (usingPhone) {
if (!PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
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 位';
setErrors(next);
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">
<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}>
<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)})}
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
<Field value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})}
placeholder="请输入手机号" error={errors.phone} inputMode="numeric" />
<Field value={form.code} onChange={change('code')} placeholder="请输入短信验证码" inputMode="numeric"
action={<VerificationButton phone={form.phone} />} />
<Field value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})}
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} />
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
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>
</form>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function PasswordReset() {
const [form, setForm] = React.useState({phone: '', code: '', password: '', confirm: ''});
const [challengeId, setChallengeId] = React.useState('');
const [error, setError] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [toast, setToast] = React.useState('');
const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => {
const submit = async e => {
e.preventDefault();
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('两次输入的密码不一致');
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">
<Header title="忘记密码" back="login" />
<main className="auth-content auth-content-narrow auth-reset-content">
<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.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" />
<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>
{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>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</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}) {
const [phone] = React.useState('138****8899');
const [phoneMask, setPhoneMask] = React.useState('');
const [challengeId, setChallengeId] = React.useState('');
const [code, setCode] = React.useState('');
const [toast, setToast] = React.useState('');
return <div className="auth-page auth-soft-bg">
const [seconds, setSeconds] = React.useState(0);
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'} />
<main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={e => { e.preventDefault(); setToast('身份验证成功'); }}>
<label><span>手机号</span><input value={phone} readOnly /></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>
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6}>确定</button>
<form className="auth-list-form" onSubmit={submit}>
<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" />
<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>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function Realname({verified = false, authenticated = false}) {
const [name, setName] = React.useState(verified ? '蒋欣' : '');
const [idNumber, setIdNumber] = React.useState(verified ? '43993838581999355454' : '');
const [status, setStatus] = React.useState(verified ? 'verified' : 'unverified');
function Realname({authenticated = false}) {
// Real status (unverified/pending/verified/rejected) drives the view -- the old mock's `verified`
// prop toggle is gone, both 'realname' and 'realname-verified' routes render this and let the
// 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 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();
if (name.trim().length < 2 || !/^\d{17}[\dXx]$/.test(idNumber)) {
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">
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
<main className="auth-realname-content">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
<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={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 ? name : maskedName} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={editable ? idNumber : maskedIdNumber} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
{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>
<p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p>
</main>
@@ -384,7 +654,7 @@ function RealnameReminder() {
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-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>;
}
@@ -393,11 +663,25 @@ function IndependentPassword({authenticated}) {
const [pin, setPin] = React.useState('');
const [error, setError] = 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 submit = e => {
const submit = async e => {
e.preventDefault();
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">
<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>
<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}
<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>
<Toast message={toast} onDone={() => setToast('')} />
</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') || '';
switch (view) {
case 'tenant': return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />;
case 'login': return <Login tenantName={tenantName} />;
case 'tenant': return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
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-feedback': return <EnterpriseFeedback />;
case 'register': return <Register />;
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck 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 '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 QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
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 = [
'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-search-result',
@@ -12,27 +25,39 @@ export const CONTACT_MANAGEMENT_VIEWS = [
'clear-chat-confirm'
];
const FRIENDS = [
{id: 'annie', name: '安妮', remark: '安妮 · 产品', initial: 'A', avatar: 'img/bkg/p09.jpg', account: 'AN1024'},
{id: 'chen', name: '陈晨', initial: 'C', avatar: 'img/bkg/p05.jpg', account: 'CHEN88'},
{id: 'lizi', name: '栗子来了', remark: '栗子', initial: 'L', avatar: 'img/bkg/p15.jpg', account: 'LIZI66'},
{id: 'wang', name: '王珊珊', initial: 'W', avatar: 'img/bkg/p14.jpg', account: 'WSS2026'}
];
const REQUESTS = [
{id: 'r1', name: '祈万事安好', message: '我是设计交流群里的小祈', time: '10:24', status: 'pending', avatar: 'img/bkg/p03.jpg'},
{id: 'r2', name: '一颗栗子', message: '你好,想认识一下', time: '昨天', status: 'accepted', avatar: 'img/bkg/p15.jpg'},
{id: 'r3', name: '林间风', message: '通过手机号搜索到你', time: '星期一', status: 'rejected', avatar: 'img/bkg/p18.jpg'}
];
// Server's own `initial` (bootstrap.go displayInitial) is just the first Unicode code point of the
// display name -- for Chinese names that's a hanzi, not a Latin letter, so it can't drive the A/B/C
// section headers docs/images/通讯录.png actually shows. Group client-side by first-pinyin-letter.
function alphaInitial(name) {
const ch = (name || '').trim()[0] || '#';
if (/[A-Za-z]/.test(ch)) {
return ch.toUpperCase();
}
if (/[一-龥]/.test(ch)) {
const letter = pinyin(ch, {pattern: 'first', toneType: 'none'});
return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#';
}
return '#';
}
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', 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>;
function Header({title, back = 'messages-home', backUid, right, onRight}) {
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}) {
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}) {
@@ -44,94 +69,434 @@ function Toast({message, onDone}) {
return message ? <div className="chat-toast" role="status">{message}</div> : null;
}
function Search({value, onChange, placeholder}) {
return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>;
function Search({value, onChange, placeholder, onSubmit}) {
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);
React.useEffect(() => {
if (!ref.current) return;
if (!ref.current || !payload) return;
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});
}, [group]);
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>;
new QRCode(ref.current, {text: payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
}, [payload]);
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 visible = FRIENDS.filter(item => `${item.name}${item.remark || ''}`.includes(query));
const groups = visible.reduce((result, item) => ({...result, [item.initial]: (result[item.initial] || []).concat(item)}), {});
// Seeded from cache so re-opening the address book (very much a back-and-forth flow: check
// 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 = [
{icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: 1, tone: 'blue'},
{icon: 'group', label: '群聊', route: 'group-list', tone: 'green'},
{icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange'},
{icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: pendingCount || null, tone: 'blue'},
{icon: 'group', label: '群组', route: 'group-list', tone: 'green'},
{icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange', fileAssistant: true},
{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}) {
const [items, setItems] = React.useState(REQUESTS);
const handle = (id, status) => {setItems(values => values.map(item => item.id === id ? {...item, status} : item)); onToast(status === 'accepted' ? '已通过好友申请' : '已拒绝好友申请');};
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>;
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
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}) {
const [query, setQuery] = React.useState(result ? 'AN1024' : '');
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>;
// `GET /users/{id}/profile` (getUserProfile) 403s for anyone who isn't already a friend, by design
// (server's default `profile_visibility` is "friends"-only, server/im_http.go's imPrivacyAllowsProfile).
// 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}) {
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}) {
const [message, setMessage] = React.useState('我是产品研发群里的陈晨');
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>;
function Verification({uid, onToast}) {
// Same seed-then-best-effort-refresh as Profile -- reached right after it (via its "添加好友"
// 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}) {
const person = friend ? FRIENDS[0] : FRIENDS[2];
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>;
function Profile({uid, friend, onTopicSelected, onToast}) {
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
// A failed fetch with no seed to fall back on previously left this stuck on "加载中…" forever --
// tracked separately from `profile` itself (which legitimately stays null while genuinely still
// 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 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 [muted, setMuted] = React.useState(false);
const [blocked, setBlocked] = React.useState(initialBlocked);
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 [editingRemark, setEditingRemark] = React.useState(false);
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}) {
const [people, setPeople] = React.useState([FRIENDS[3], FRIENDS[2]]);
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 [people, setPeople] = React.useState([]);
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 = {
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除'},
block: {title: '加入黑名单', text: '加入黑名单后,你将不再收到对方的消息和通话。', action: '确认拉黑', route: 'friend-settings-blocked', toast: '已加入黑名单'},
clear: {title: '清除聊天记录', text: '仅清除你自己的聊天记录,不会影响对方,也不会删除已收藏内容。', action: '确认清除', route: 'friend-settings', toast: '聊天记录已清除'}
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除', keepUid: false},
block: {title: '黑名单', text: '拉黑后将不会收到对方的消息,可在"设置"—"黑名单"查看人员名单', action: '确定', route: 'friend-settings-blocked', toast: '已加入黑名单', keepUid: true},
clear: {title: '清除聊天记录', text: '清空后不可找回,确定要清空当前会话的历史记录吗?', action: '确定', route: 'friend-settings', toast: '聊天记录已清除', keepUid: true}
}[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 uid = hashParams && hashParams.uid;
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 === '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 === 'friend-verification') content = <Verification onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile friend={view === 'friend-profile'} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'block-list') content = <BlockList onToast={setToast} />;
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" onToast={setToast} />;
else if (view === 'block-confirm') content = <ConfirmSheet type="block" onToast={setToast} />;
else content = <ConfirmSheet type="clear" 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" uid={uid} tinode={tinode} onToast={setToast} />;
else content = <ConfirmSheet type="clear" uid={uid} tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}
+477 -81
View File
@@ -1,8 +1,41 @@
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 = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
@@ -11,121 +44,484 @@ export const CONTENT_LIBRARY_VIEWS = [
'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 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 Avatar({item}) { return <span className="library-avatar">{item.image ? <img src={item.image} alt="" /> : <Icon>{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 Header({title, back = 'messages-home', backParams, right, onRight}) {
return <header className="library-header">
<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 FileCard({item, onOpen}) {
const removed = item.state === 'removed';
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 extraction helpers: a favorite's snapshot.content (and a history item's flattened content
// fields) are literally the original message's own content, not a Ch.7-specific shape -- reuse the same
// 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}) {
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>;
function FileCard({name, size, mime, sender, removed, onOpen}) {
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}) {
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>;
// Real playback via the same widgets/audio-player.jsx used for live voice messages, rather than the
// 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' : '')}>
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
<div className="library-favorite-content">
{item.type === 'text' ? <p className="library-text-content">{item.text}</p> : null}
{item.type === 'file' ? <FileCard item={item} onOpen={onOpen} /> : null}
{item.type === 'voice' ? <VoiceCard item={item} playing={playing} onPlay={onPlay} /> : null}
{item.type === 'link' ? <LinkCard item={item} 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}
<footer><span>来自 {item.source}</span><time>{item.time}</time><button type="button" aria-label="更多操作"><Icon>more_horiz</Icon></button></footer>
{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 name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime} onOpen={onOpen} /> : null}
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} /> : 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' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} /> : null}
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div>
</article>;
}
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 selecting = view === 'favorites-select';
const [type, setType] = React.useState(selecting ? 'file' : initialType);
const [query, setQuery] = React.useState('');
const [playing, setPlaying] = React.useState(null);
const [selected, setSelected] = React.useState(selecting ? ['file'] : []);
const [items, setItems] = React.useState(FAVORITES);
const visible = items.filter(item => (type === 'all' || item.type === type) && JSON.stringify(item).toLowerCase().includes(query.toLowerCase()));
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
const deleteSelected = () => {setItems(values => values.filter(item => !selected.includes(item.id))); setSelected([]); onToast('已删除所选收藏');};
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 [selected, setSelected] = React.useState([]);
// Seeded per-type -- one Favorites instance backs every favorites* tab/route (confirmed multiple
// entry points: personal-settings' "我的收藏" row, direct-message-view.jsx's attachment-panel
// "favorite" action), so an unparameterized key would flash a stale, wrong-type list under a
// freshly-switched tab. `query` is deliberately excluded -- it always resets to '' on a tab switch,
// so caching a search result under the same key would show it under a plain (non-search) revisit.
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}) {
const [query, setQuery] = React.useState(''); const [selected, setSelected] = React.useState([]); const [confirming, setConfirming] = React.useState(false); const [comment, setComment] = React.useState('');
const visible = TARGETS.filter(item => `${item.name}${item.note}`.includes(query));
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id).slice(0, 9));
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>;
// Reuses the exact same real-contact-list source as Ch.4's batch-forward-dialog.jsx (the app's own
// already-subscribed chatList, filtered locally) rather than the pre-built but never-actually-wired
// getForwardTargets() -- that endpoint's field shape was never verified against a real caller anywhere
// in this codebase, while this pattern is proven live since Chapter 4.
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}) {
const initial = view === 'file-downloaded' ? 100 : view === 'file-downloading' ? 36 : 0;
const [progress, setProgress] = React.useState(initial); const [paused, setPaused] = React.useState(false);
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]);
React.useEffect(() => {if (progress === 100 && view === 'file-downloading') navigate('file-downloaded');}, [progress, view]);
const state = progress === 100 ? 'done' : progress > 0 ? 'loading' : 'ready';
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>;
function DownloadPage({hashParams, tinode, onToast}) {
const [meta, setMeta] = React.useState(null);
// A failed fetch previously left this stuck on "加载中…" forever -- the toast fired once and faded,
// but `meta` never left null so the loading branch below never had anything else to show. Tracked
// separately from `meta` itself since a real "still loading" state also has `meta === null`.
const [loadError, setLoadError] = React.useState(false);
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}) {
if (!query) return text;
const index = text.indexOf(query); if (index < 0) return text;
return <>{text.slice(0, index)}<mark>{query}</mark>{text.slice(index + query.length)}</>;
function Highlight({text, ranges}) {
if (!ranges || !ranges.length) return text;
const parts = []; let last = 0;
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}) {
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>;
}
function History({view, onToast}) {
const [query, setQuery] = React.useState(view === 'history-search' ? '评审' : '');
const messages = [{id: 1, sender: '安妮', text: '下午三点进行版本评审,请大家提前准备。', time: '今天 09:30'}, {id: 2, sender: '陈晨', text: '评审结论已经整理到产品需求文档。', time: '今天 16:42'}];
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>;
const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
const HISTORY_TYPE = {'history-search': 'all', 'history-files': 'file', 'history-links': 'link', 'history-images': 'image', 'history-videos': 'video'};
// Same bracket-label convention as message-center-view.jsx's draftyPreview() -- messageText() on the
// 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}) {
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}) {
export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
const [toast, setToast] = React.useState(''); let content;
if (view.startsWith('favorites')) content = <Favorites view={view} onToast={setToast} />;
else if (view === 'favorite-share') content = <FavoriteShare onToast={setToast} />;
else if (view.startsWith('file-download')) content = <DownloadPage view={view} onToast={setToast} />;
else content = <History view={view} onToast={setToast} />;
if (view.startsWith('favorites')) content = <Favorites view={view} tinode={tinode} onToast={setToast} />;
else if (view === 'favorite-share') content = <FavoriteShare hashParams={hashParams} myUserId={myUserId} chatList={chatList} onToast={setToast} />;
else if (view.startsWith('file-download')) content = <DownloadPage hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <History view={view} hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
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 { Tinode } from 'tinode-sdk';
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 = [
{id: 'system', title: '系统消息', summary: '您有一条新的安全通知', time: '昨天 21:03', icon: 'notifications', tone: 'orange', system: true},
{id: 'sun', title: '孙光洪', summary: '最近忙什么', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p03.jpg', online: true},
{id: 'files', title: '文件助手', summary: '你上传了 1 份文件', time: '昨天 21:03', icon: 'folder', tone: 'green', fileAssistant: true},
{id: 'zhao', title: '赵吾行', summary: '好久不见,怎样了,都挺好的', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p07.jpg'},
{id: 'wang', title: '王厚哲', summary: '恭喜你呀', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p12.jpg', online: true},
{id: 'sunmu', title: '孙牧森', summary: '真有意思', time: '昨天 21:03', muted: true, avatar: 'img/bkg/p18.jpg'}
];
const FREQUENT_CONTACTS = [
{id: 'a', name: '吃火锅', avatar: 'img/bkg/p02.jpg', online: true},
{id: 'b', name: '爱上草莓', avatar: 'img/bkg/p05.jpg', online: true},
{id: 'c', name: '好久鼻尖', avatar: 'img/bkg/p09.jpg'},
{id: 'd', name: '太想念你', avatar: 'img/bkg/p14.jpg', online: true},
{id: 'e', name: '夏纳', avatar: 'img/bkg/p21.jpg'}
];
const SYSTEM_NOTIFICATIONS = [
{
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: '扫一扫'}
];
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
// a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently
// contacted" data doesn't exist as its own bootstrap field, so this derives it from the same
// `chatList` the conversation list below already uses: P2P topics only (a "people you talk to" rail
// isn't the place for groups), most-recently-touched first.
function frequentContacts(chatList) {
if (!chatList || chatList.length === 0) return [];
return chatList
.filter(item => item.topic && Tinode.isP2PTopicName(item.topic))
.slice()
.sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime())
.slice(0, 8)
.map(item => ({
id: item.topic,
name: (item.public && item.public.fn) || '未命名',
avatar: makeImageUrl(item.public && item.public.photo),
online: !!item.online
}));
}
// 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 = [
{id: 'quote', icon: 'format_quote', label: '引用'},
{id: 'copy', icon: 'content_copy', label: '复制'},
{id: 'favorite', icon: 'inventory_2', label: '收藏'},
{id: 'translate', icon: 'translate', label: '翻译'},
{id: 'forward', icon: 'reply', label: '转发'},
{id: 'recall', icon: 'undo', label: '撤回'},
{id: 'delete', icon: 'delete_outline', label: '删除'},
{id: 'recall', icon: 'undo', label: '撤回', gated: true},
{id: 'delete', icon: 'delete_outline', label: '删除', gated: true},
{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}) {
@@ -66,25 +65,77 @@ function Toast({message, onDone}) {
}
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}>
{item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}
{item.online !== undefined ? <span className={item.online ? 'online' : 'offline'} /> : null}
</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) {
if (!chatList || chatList.length === 0) return DEMO_CONVERSATIONS;
if (!chatList || chatList.length === 0) return [];
return chatList.map((item, index) => {
const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null;
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;
return {
id: item.topic || item.user || String(index),
title: item.public && item.public.fn ? item.public.fn : '未命名会话',
title: topicDisplayName(item.topic, item.public, '未命名会话'),
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),
online: !!item.online,
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
@@ -100,27 +151,41 @@ function BottomNavigation() {
</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}>
<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>
</button>)}
</div>
</div>;
}
function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOpenScanner}) {
const [query, setQuery] = React.useState('');
const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu');
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 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 frequent = frequentContacts(chatList);
const selectConversation = item => {
if (item.system || item.id === 'system') {
if (item.system) {
navigate('system-messages');
} else if (item.fileAssistant && item.topic && onTopicSelected) {
onTopicSelected(item.topic);
} else if (chatList && chatList.length && onTopicSelected) {
onTopicSelected(item.id);
} else {
@@ -134,7 +199,8 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
if (item.id === 'profile') return navigate('personal-center');
if (item.id === 'create-group') return navigate('group-list');
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">
@@ -144,14 +210,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
</header>
<main className="messages-home-content">
<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>
<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>
</button>)}
</div>
</section>
</section> : null}
<section className="messages-conversations" aria-label="会话列表">
{filtered.map(item => <button type="button" className={'messages-conversation' + (item.pinned ? ' pinned' : '')}
key={item.id} onClick={() => selectConversation(item)}>
@@ -165,7 +231,7 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
</section>
</main>
<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('')} />
</div>;
}
@@ -174,6 +240,17 @@ function MessageActions() {
const [visible, setVisible] = React.useState(true);
const [toast, setToast] = 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 => {
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>
{visible ? <div className="message-action-backdrop" onMouseDown={() => setVisible(false)}>
<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>
</button>)}
</div>
@@ -205,20 +282,40 @@ function MessageActions() {
}
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 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">
<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">
{items.map(item => <article key={item.id} className={item.read ? 'read' : ''} onClick={() => markRead(item.id)}>
<time>{item.date}</time>
{loading ? <p className="messages-empty"><Icon>hourglass_empty</Icon>加载中…</p> : null}
{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">
<header><h2>{item.title}</h2>{!item.read ? <span>新</span> : null}</header>
<p>亲爱的用户,您的账号于 <button type="button" onClick={() => setToast('登录时间:' + item.loginAt)}>{item.loginAt}</button> 在新设备上登录成功。</p>
<dl><div><dt>设备:</dt><dd>{item.device}</dd></div><div><dt>IP地址:</dt><dd>{item.ip}</dd></div></dl>
<p>如果不是本人操作,请及时修改密码并检查登录设备。</p>
<button type="button" className="system-security-link" onClick={() => navigate(authenticated ? 'security' : 'identity-check')}>前往安全设置 <Icon>chevron_right</Icon></button>
<header><h2>{item.title}</h2>{!item.read_at ? <span>新</span> : null}</header>
<p>{item.summary}</p>
{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}
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button>
<footer>保持警惕<br />即时通讯安全团队</footer>
</div>
</article>)}
+372 -28
View File
@@ -18,6 +18,7 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import MetaMessage from '../widgets/meta-message.jsx';
import PinnedMessages from '../widgets/pinned-messages.jsx';
import { RiskConfirmDialog } from './security-support-view.jsx';
import SendMessage from '../widgets/send-message.jsx';
const TheCardPreview = React.lazy(_ => import('../widgets/the-card-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';
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -141,10 +145,17 @@ class MessagesView extends React.Component {
super(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.leave = this.leave.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.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendVideoAttachment = this.sendVideoAttachment.bind(this);
@@ -170,6 +181,13 @@ class MessagesView extends React.Component {
this.handleShowMessageContextMenu = this.handleShowMessageContextMenu.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.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.handleAttachImageOrVideo = this.handleAttachImageOrVideo.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
@@ -182,6 +200,7 @@ class MessagesView extends React.Component {
this.handleEditMessage = this.handleEditMessage.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.jumpToMessage = this.jumpToMessage.bind(this);
this.handleUnpinMessage = this.handleUnpinMessage.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this);
@@ -249,12 +268,17 @@ class MessagesView extends React.Component {
this.mountDnDEvents(this.dndRef);
this.componentSetup({}, {});
getBootstrap().then(data => {
this.setState({messagePolicy: data.message_policy || null});
}).catch(_ => {});
}
componentWillUnmount() {
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
window.clearTimeout(this.toastTimer);
// Flush all notifications.
this.clearNotificationQueue();
@@ -290,6 +314,13 @@ class MessagesView extends React.Component {
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);
}
@@ -312,6 +343,9 @@ class MessagesView extends React.Component {
topic.onPres = this.handleSubsUpdated;
topic.onAuxUpdated = this.handleAuxUpdate;
}
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
}
if (topic) {
@@ -368,7 +402,12 @@ class MessagesView extends React.Component {
pins: [],
pinsLoaded: false,
selectedPin: 0,
subsVersion: 0
subsVersion: 0,
relationshipContext: null,
groupRoles: null,
myGroupRole: null,
groupAnnouncement: null,
pendingJoinRequests: 0
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -389,7 +428,18 @@ class MessagesView extends React.Component {
showGoToLastButton: false,
contentToEdit: null,
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) {
@@ -435,7 +485,7 @@ class MessagesView extends React.Component {
});
} else if (topic.public) {
Object.assign(nextState, {
title: topic.public.fn,
title: topicDisplayName(topic.name, topic.public, ''),
avatar: makeImageUrl(topic.public.photo)
});
} else {
@@ -517,7 +567,7 @@ class MessagesView extends React.Component {
nextState.isAdmin = false;
}
if (!prevState.readingBlocked) {
prevState.readingBlocked = true;
nextState.readingBlocked = true;
}
if (prevState.isSharer) {
nextState.isSharer = false;
@@ -692,7 +742,7 @@ class MessagesView extends React.Component {
});
} else if (desc.public) {
this.setState({
title: desc.public.fn,
title: topicDisplayName(this.props.topic, desc.public, ''),
avatar: makeImageUrl(desc.public.photo)
});
} else {
@@ -968,9 +1018,28 @@ class MessagesView extends React.Component {
params.userName = this.state.title;
}
params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || [];
let menuItems = messageSpecificMenuItems || [];
const topic = this.props.tinode.getTopic(params.topicName);
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.
// 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.
@@ -1002,6 +1071,7 @@ class MessagesView extends React.Component {
}
}
}
menuItems.push('menu_item_select');
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);
}
// 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) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic.isSubscribed()) {
if (audio) {
topic.noteRecording(true);
} else {
topic.noteKeyPress();
}
if (!topic.isSubscribed()) {
return;
}
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(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;
if (!msg && this.props.forwardMessage) {
// We are forwarding a message.
@@ -1053,7 +1260,59 @@ class MessagesView extends React.Component {
}
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.
@@ -1090,7 +1349,7 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) {
// 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) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1103,7 +1362,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'file');
} else {
// Small enough to send inband.
fileToBase64(file)
@@ -1112,7 +1371,7 @@ class MessagesView extends React.Component {
data: b64.bits,
filename: b64.name,
size: file.size
})))
}), undefined, undefined, 'file'))
.catch(err => this.props.onError(err.message, 'err'));
}
}
@@ -1185,7 +1444,7 @@ class MessagesView extends React.Component {
if (blob.size > maxInbandAttachmentSize) {
// 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) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1212,7 +1471,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// 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'));
return;
@@ -1234,7 +1493,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
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;
if ((videoBlob.size + previewBlob.size) > maxInbandAttachmentSize) {
// 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) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1281,7 +1540,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption));
}
this.sendMessage(msg);
this.sendMessage(msg, undefined, undefined, 'video');
});
return;
}
@@ -1320,7 +1579,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// 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'));
}
@@ -1379,7 +1638,7 @@ class MessagesView extends React.Component {
const maxInbandAttachmentSize = this.props.tinode.getServerParam('maxMessageSize', MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024;
if (blob.size > maxInbandAttachmentSize) {
// 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) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
@@ -1393,7 +1652,7 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.sendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader, 'voice');
} else {
// Small enough to send inband.
blobToBase64(blob)
@@ -1404,7 +1663,7 @@ class MessagesView extends React.Component {
size: blob.size,
duration: duration,
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) {
const topic = this.props.tinode.getTopic(this.state.topic);
topic.pinMessage(seq, false);
@@ -1568,6 +1848,10 @@ class MessagesView extends React.Component {
onSendAnswer={this.props.onCallSendAnswer} />
</Suspense>;
const riskDialog = this.state.riskDialog &&
<RiskConfirmDialog blocked={this.state.riskDialog.blocked}
onConfirm={this.handleRiskConfirm} onCancel={this.handleRiskCancel} />;
let component;
if (!this.state.topic) {
component = <LogoView
@@ -1733,6 +2017,7 @@ class MessagesView extends React.Component {
topic={this.state.topic}
content={msg.content}
mimeType={msg.head && msg.head.mime}
head={msg.head}
replyToSeq={replyToSeq}
edited={msg.head && !msg.head.webrtc && msg.head.replace}
timestamp={msg.ts}
@@ -1743,11 +2028,17 @@ class MessagesView extends React.Component {
userFrom={userFrom}
userName={userName}
userAvatar={userAvatar}
senderRole={groupTopic && this.state.groupRoles ? this.state.groupRoles[thisFrom] : null}
sequence={sequence}
received={deliveryStatus}
uploader={msg._uploader}
userIsWriter={this.state.isWriter}
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)}
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
showContextMenu={this.handleShowMessageContextMenu}
@@ -1797,6 +2088,18 @@ class MessagesView extends React.Component {
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}>
<button id="go-to-latest" className={'action-button' + (this.state.showGoToLastButton ? '' : ' hidden')}
onClick={this.goToLatestMessage}>
@@ -1817,6 +2120,9 @@ class MessagesView extends React.Component {
: null }
</div>
: null }
<div className={'toast' + (this.state.toast ? ' show' : '')}>
<i className="material-icons">check</i>&nbsp;{this.state.toast}
</div>
</div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note">
@@ -1826,13 +2132,51 @@ class MessagesView extends React.Component {
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
</div> : null}
{this.state.unconfirmed ?
{!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} />
:
<SendMessage
tinode={this.props.tinode}
topicName={this.state.topic}
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}
reply={this.state.reply}
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;
}
+610 -74
View File
@@ -2,31 +2,41 @@ import React from 'react';
import QRCode from 'qrcodejs';
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 = [
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
'profile-signature', 'personal-qrcode', 'android-settings', 'cache-clear-confirm',
'app-version', 'app-update-available', 'privacy-settings', 'friend-discovery-settings',
'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on', 'youth-mode-disable',
'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
'app-version', 'privacy-settings', 'friend-discovery-settings',
'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
'notification-permission', 'permission-settings-reminder', 'camera-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 Header({title, back = 'personal-center', action, actionLabel, onAction}) {
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>;
function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
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}) {
@@ -39,123 +49,649 @@ function Toast({message, onDone}) {
}
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}) {
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() {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
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>;
// Was `useState(null)` + its own effect -- remounting this (e.g. tabbing back from 设置) blanked
// 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}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
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>;
// docs/images/个人信息.png 两张截图字段不完全一样(一张有用户名/职位/公司,另一张有ID号/地区)--
// 代码调研确认 /me/profile 真实字段只有 avatar/nickname/gender/birth_date/region/signature/email;
// "职位/公司"属于第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}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
const [value, setValue] = React.useState(profile[field] || '');
// Real field_rules confirmed against server/im_http.go: nickname max_length=32, signature
// max_length=120 -- the reference screenshots show 120/20 respectively (swapped and wrong),
// 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 normalized = value.trim();
if (field === 'nickname' && !normalized) return onToast('昵称不能为空');
const next = {...profile, [field]: field === 'signature' ? value.trim() : normalized};
window.localStorage.setItem(PROFILE_KEY, JSON.stringify(next));
onToast('资料已保存');
window.setTimeout(() => navigate('personal-profile'), 650);
if (field === 'nickname' && !normalized) { onToast('昵称不能为空'); return; }
setSaving(true);
updateMeProfile({[field]: normalized, version: profile.version}).then(_ => {
onToast('资料已保存');
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}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
const [value, setValue] = React.useState(profile.birthDate);
const save = () => {window.localStorage.setItem(PROFILE_KEY, JSON.stringify({...profile, birthDate: value})); onToast('出生日期已保存'); window.setTimeout(() => navigate('personal-profile'), 650);};
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 [profile, setProfile] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.birth_date || ''); }).catch(() => {}); }, []);
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}) {
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
const [token, setToken] = React.useState(Date.now());
// Same shared 'me-profile' key as PersonalCenter/PersonalProfile -- this only needs it for the
// 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 load = () => {
createMyQRCode().then(setQr).catch(err => onToast(err.message || '二维码生成失败'));
};
React.useEffect(() => {
getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
load();
}, []);
React.useEffect(() => {
if (!qr || !qrRef.current) return;
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});
}, [token]);
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>;
new QRCode(qrRef.current, {text: qr.qr_payload || qr.qr_token, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});
}, [qr]);
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}) {
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>;
// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分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}) {
const [cleared, setCleared] = 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 [busy, setBusy] = React.useState(false);
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}) {
const [checking, setChecking] = React.useState(false);
const check = () => {setChecking(true); window.setTimeout(() => {setChecking(false); navigate('app-update-available');}, 700);};
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>;
function ClearHistoryConfirm({tinode, chatList, onToast}) {
const [busy, setBusy] = React.useState(false);
const confirm = () => {
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}) {
const initial = readObject(PRIVACY_KEY, {groupVerification: true, readReceipts: true, lastSeen: true});
const [settings, setSettings] = React.useState(initial);
const update = (key, value) => {const next = {...settings, [key]: value}; setSettings(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('隐私设置已保存');};
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>;
// Shares 'me-privacy' with FriendDiscoverySettings' identical getMePrivacy() call -- a toggle page,
// not a text edit form, so a background refetch self-corrects any stale toggle state harmlessly
// (no risk of clobbering in-progress typing the way a free-text field would).
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}) {
const initial = readObject(PRIVACY_KEY, {account: true, phone: true, qr: true, group: true});
const [methods, setMethods] = React.useState(initial);
const update = (key, value) => {const next = {...methods, [key]: value}; setMethods(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('添加方式已更新');};
// Same shared 'me-privacy' key as PrivacySettings -- identical resource, so whichever of the two
// screens is opened first warms the cache for the other.
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: '群聊'}];
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}) {
const enabled = state === 'on' || state === 'disable';
function useYouthUsageSeconds(active) {
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 enable = () => {window.localStorage.setItem(YOUTH_KEY, 'enabled'); onToast('青少年模式已开启'); window.setTimeout(() => navigate('youth-mode-on'), 650);};
const disable = () => {if (password.length < 6) return onToast('请输入 6 位独立密码'); window.localStorage.removeItem(YOUTH_KEY); setPassword(''); onToast('青少年模式已关闭'); window.setTimeout(() => navigate('youth-mode-off'), 650);};
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>;
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
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 = {
notification: {icon: 'notifications_active', title: '获取通知权限', text: '用于及时送达新消息、会议提醒和安全通知。', action: '开启通知', route: 'permission-settings-reminder'},
reminder: {icon: 'settings', title: '消息推送暂未开启', text: '请前往 Android 系统设置,在通知管理中允许光讯通讯发送通知。', action: '前往设置', route: 'android-settings'},
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '用于拍照、扫码和视频通话。拒绝后仍可从相册选择图片。', action: '继续授权', route: 'messages-home'},
notification: {icon: 'notifications_active', title: '获取推送权限', text: '向您及时送达重要通知、活动提醒、个性化内容更新等信息。', action: '开启通知', route: 'permission-settings-reminder'},
reminder: {icon: 'settings', title: '消息推送暂未开启', text: `前往【设置 - 通知管理】一键开启吧!`, action: '好的', secondaryAction: '暂不开启', route: 'android-settings'},
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '调用相机拍摄/扫码,为您提供拍照、扫码识别等服务。', action: '继续授权', route: 'messages-home'},
microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'}
};
function PermissionGuide({type, onToast}) {
const config = PERMISSIONS[type];
const proceed = () => {onToast(type === 'reminder' ? '已打开 Android 应用设置' : '等待 Android 系统授权结果'); window.setTimeout(() => navigate(config.route), 650);};
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>;
const proceed = () => {
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('');
let content;
if (view === 'personal-center') content = <PersonalCenter />;
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} />;
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" maxLength={32} onToast={setToast} />;
else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" maxLength={120} multiline onToast={setToast} />;
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} tinode={tinode} />;
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" 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-gender') content = <GenderEditor onToast={setToast} />;
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
else if (view === 'android-settings') content = <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 === '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 === '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} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}
+205 -45
View File
@@ -1,29 +1,26 @@
import React from 'react';
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 = [
'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons',
'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 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}) {
@@ -35,53 +32,216 @@ function Toast({message, onDone}) {
return message ? <div className="support-toast" role="status">{message}</div> : null;
}
function ConversationPreview() {
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>;
// Backend has no "content risk scan" or "post-send fraud alert" mechanism (confirmed against
// 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}) {
const continueAction = () => {
if (!secondary) return navigate('fraud-risk-confirmation');
onToast('已记录风险确认,将返回原操作');
window.setTimeout(() => navigate('chat-standard'), 750);
};
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>;
// Exported for messages-view.jsx to render inline when composing a message that contains a URL --
// this is the one piece of Ch.11 with a genuine backend mechanism behind it (checkURL/confirmRiskChallenge,
// verified against im_http.go:1184-1285). `level`/`blocked` come straight from the checkURL response.
export function RiskConfirmDialog({level, blocked, onConfirm, onCancel}) {
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>
<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() {
const select = reason => {window.sessionStorage.setItem(REPORT_REASON_KEY, JSON.stringify(reason)); navigate('abuse-report-details');};
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>;
// target_type is one of user/group/message only (verified against server/store/store.go's
// AbuseReasons validation and im_http.go's normalizeIMAbuseTarget switch -- there is no
// "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}) {
const reason = (() => {try { return JSON.parse(window.sessionStorage.getItem(REPORT_REASON_KEY)) || REPORT_REASONS[0]; } catch (_) { return REPORT_REASONS[0]; }})();
function ReportDetails({hashParams, tinode, onToast}) {
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 [evidence, setEvidence] = React.useState([]);
const [submitted, setSubmitted] = 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 submit = () => {if (reason.required && !description.trim()) return onToast('请补充问题描述'); setSubmitted(true);};
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 [uploading, setUploading] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(null);
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}) {
const [category, setCategory] = React.useState('bug');
// category is REQUIRED server-side (POST /feedback rejects empty category) but the reference image
// (意见反馈.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 [contact, setContact] = React.useState('');
const [attachments, setAttachments] = React.useState(0);
const [images, setImages] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [diagnostics, setDiagnostics] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const submit = () => {if (!content.trim()) return onToast('请填写反馈内容'); setSubmitted(true);};
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 categories = [{id: 'bug', label: '功能异常', icon: 'bug_report'}, {id: 'suggestion', label: '产品建议', icon: 'lightbulb'}, {id: 'complaint', label: '服务投诉', icon: 'support_agent'}, {id: 'other', label: '其他', icon: 'more_horiz'}];
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 [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(null);
const fileInputRef = React.useRef(null);
const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
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('');
let content;
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <RiskWarning secondary={view === 'fraud-risk-confirmation'} onToast={setToast} />;
else if (view === 'abuse-report-reasons') content = <ReportReasons />;
else if (view === 'abuse-report-details') content = <ReportDetails onToast={setToast} />;
else content = <UserFeedback 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 hashParams={hashParams} onToast={setToast} />;
else if (view === 'abuse-report-details') content = <ReportDetails hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <UserFeedback tinode={tinode} onToast={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)) {
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>;
}
@@ -147,7 +160,8 @@ class SidepanelView extends React.PureComponent {
connected={this.props.connected}
chatList={this.props.chatList}
myUserId={this.props.myUserId}
onTopicSelected={this.props.onTopicSelected} />
onTopicSelected={this.props.onTopicSelected}
onOpenScanner={this.props.onOpenScanner} />
</div>;
}
@@ -168,19 +182,22 @@ class SidepanelView extends React.PureComponent {
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
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>;
}
if (GROUP_MANAGEMENT_VIEWS.includes(view)) {
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>;
}
if (CONTENT_LIBRARY_VIEWS.includes(view)) {
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>;
}
@@ -192,19 +209,23 @@ class SidepanelView extends React.PureComponent {
if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) {
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>;
}
if (PERSONAL_SETTINGS_VIEWS.includes(view)) {
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>;
}
if (SECURITY_SUPPORT_VIEWS.includes(view)) {
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>;
}
@@ -331,7 +352,7 @@ class SidepanelView extends React.PureComponent {
onLogout={this.props.onLogout}
onDeleteAccount={this.props.onDeleteAccount}
onShowAlert={this.props.onShowAlert}
onShowBlocked={() => this.props.onNavigate('block-list')}
onShowBlocked={this.props.onShowBlocked}
onToggleIncognitoMode={this.props.onToggleIncognitoMode} />
</Suspense> :
+344 -21
View File
@@ -11,7 +11,9 @@ import { Drafty, Tinode } from 'tinode-sdk';
import Alert from '../widgets/alert.jsx';
import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx';
import BatchForwardDialog from '../widgets/batch-forward-dialog.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 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 { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.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 { 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.
const POP_SOUND = new Audio('audio/msg.m4a');
@@ -119,6 +123,7 @@ class TinodeWeb extends React.Component {
this.handleAutoreconnectIteration = this.handleAutoreconnectIteration.bind(this);
this.doLogin = this.doLogin.bind(this);
this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
this.handleTenantResolved = this.handleTenantResolved.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this);
this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
@@ -133,6 +138,7 @@ class TinodeWeb extends React.Component {
this.handleNewChatInvitation = this.handleNewChatInvitation.bind(this);
this.handleNewAccount = this.handleNewAccount.bind(this);
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
this.handleImRegisterRequest = this.handleImRegisterRequest.bind(this);
this.handleUpdatePasswordRequest = this.handleUpdatePasswordRequest.bind(this);
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
this.handleToggleIncognitoMode = this.handleToggleIncognitoMode.bind(this);
@@ -182,6 +188,18 @@ class TinodeWeb extends React.Component {
this.handleShowForwardDialog = this.handleShowForwardDialog.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.handleStartAudioCall = this.handleStartAudioCall.bind(this);
this.handleInfoMessage = this.handleInfoMessage.bind(this);
@@ -253,6 +271,7 @@ class TinodeWeb extends React.Component {
errorActionText: null,
sidePanelSelected: 'tenant',
sidePanelParams: {},
sidePanelTitle: null,
sidePanelAvatar: null,
myTrustedBadges: [],
@@ -289,6 +308,21 @@ class TinodeWeb extends React.Component {
forwardDialogVisible: false,
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.
alertVisible: false,
alertParams: {},
@@ -357,7 +391,12 @@ class TinodeWeb extends React.Component {
});
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.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
@@ -423,9 +462,46 @@ class TinodeWeb extends React.Component {
secure: secureConnection, persist: persistentCache}, onSetupCompleted);
tinode.setHumanLanguage(locale);
tinode.enableLogging(LOGGING_ENABLED, true);
TinodeWeb.installTenantHiPatch(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.
handlePushMessage(payload) {
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-new-request','group-announcement','group-summary','group-announcement-detail',
'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-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'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',
'video-call-peer-camera-off','call-minimized',
'workbench','organization-directory','meeting-organizer','meeting-attendee',
'meeting-create','meeting-detail',
'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail',
'monthly-report-editor','monthly-report-list','monthly-report-detail',
'executive-mailbox','executive-mailbox-notice',
'personal-center','personal-profile','profile-nickname','profile-birth-date',
'profile-signature','personal-qrcode','android-settings','cache-clear-confirm',
'app-version','app-update-available','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-confirm','youth-mode-on','youth-mode-disable',
'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings',
'cache-clear-confirm','clear-history-confirm','change-password','delete-account-confirm',
'app-version','privacy-settings','friend-discovery-settings',
'youth-mode-off','youth-mode-on','youth-mode-verify',
'notification-permission','permission-settings-reminder','camera-permission-info',
'microphone-permission-info',
'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons',
'abuse-report-details','user-feedback',
''].includes(hash.path[0])) {
newState.sidePanelSelected = hash.path[0];
newState.sidePanelParams = hash.params;
} else {
console.warn("Unknown sidepanel view", hash.path[0]);
}
@@ -638,7 +717,7 @@ class TinodeWeb extends React.Component {
}
} else {
// Empty hashpath
Object.assign(newState, {sidePanelSelected: '', topicSelected: null});
Object.assign(newState, {sidePanelSelected: '', sidePanelParams: {}, topicSelected: null});
}
// Save credential validation parameters, if available.
@@ -890,6 +969,11 @@ class TinodeWeb extends React.Component {
if (LocalStorageUtil.getObject('keep-logged-in')) {
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.
// Preserve the open topic so it gets restored after re-login.
@@ -924,7 +1008,10 @@ class TinodeWeb extends React.Component {
}).finally(_ => {
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) {
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) {
// Make state undefined.
this.setState({incognitoMode: null});
@@ -1424,6 +1528,36 @@ class TinodeWeb extends React.Component {
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'.
handleShowArchive() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
@@ -1642,6 +1776,7 @@ class TinodeWeb extends React.Component {
localStorage.removeItem('auth-token');
localStorage.removeItem('firebase-token');
localStorage.removeItem('settings');
sessionStorage.removeItem('im-session-token');
if (this.state.firebaseToken && this.fcm) {
// Unsubscribe failures (e.g. 403 token-unsubscribe-failed) should not block logout.
firebaseDelToken(this.fcm).catch(err => {
@@ -1666,7 +1801,9 @@ class TinodeWeb extends React.Component {
this.setState(this.getBlankState());
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.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
@@ -1679,7 +1816,7 @@ class TinodeWeb extends React.Component {
}
handleDeleteAccount() {
this.tinode.delCurrentUser(true).then(_ => {
return this.tinode.delCurrentUser(true).then(_ => {
this.handleLogout();
});
}
@@ -1744,14 +1881,24 @@ class TinodeWeb extends React.Component {
return;
}
// Publish spam report.
this.tinode.report('report', topicName);
// The native `this.tinode.report('report', topicName)` call (WS pub to the "sys" topic) requires
// 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')
.then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
let hash = 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'));
}
@@ -1876,9 +2023,113 @@ class TinodeWeb extends React.Component {
}
} else if (action == 'menu_item_forward') {
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) {
this.setState({
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() {
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'));
this.setState({infoPanel: 'info'});
}
@@ -2179,7 +2458,7 @@ class TinodeWeb extends React.Component {
const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events',
'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary',
'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-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select',
'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',
'call-minimized'].includes(activeProductView);
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',
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
'profile-birth-date', 'profile-signature', 'personal-qrcode', 'android-settings',
'cache-clear-confirm', 'app-version', 'app-update-available', 'privacy-settings',
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on',
'youth-mode-disable', 'notification-permission', 'permission-settings-reminder',
'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
'app-version', 'privacy-settings',
'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);
const securitySupportActive = ['fraud-risk-warning', 'fraud-risk-confirmation',
'abuse-report-reasons', 'abuse-report-details', 'user-feedback'].includes(activeProductView);
@@ -2240,6 +2520,24 @@ class TinodeWeb extends React.Component {
:
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 ?
<CallIncoming
tinode={this.tinode}
@@ -2255,6 +2553,20 @@ class TinodeWeb extends React.Component {
:
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 ?
<Alert
title={this.state.alertParams.title}
@@ -2272,6 +2584,7 @@ class TinodeWeb extends React.Component {
connected={this.state.connected}
displayMobile={this.state.displayMobile}
state={this.state.sidePanelSelected}
hashParams={this.state.sidePanelParams}
title={this.state.sidePanelTitle}
avatar={this.state.sidePanelAvatar}
trustedBadges={this.state.myTrustedBadges}
@@ -2314,8 +2627,10 @@ class TinodeWeb extends React.Component {
onSettings={this.handleSettings}
onNavigate={this.basicNavigator}
onLoginRequest={this.handleLoginRequest}
onTenantResolved={this.handleTenantResolved}
onPersistenceChange={this.handlePersistenceChange}
onCreateAccount={this.handleNewAccountRequest}
onImRegisterRequest={this.handleImRegisterRequest}
onUpdateAccountDesc={this.handleTopicUpdateRequest}
onUpdatePassword={this.handleUpdatePasswordRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
@@ -2331,6 +2646,7 @@ class TinodeWeb extends React.Component {
onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected}
onOpenScanner={this.handleOpenScanner}
onCreateTopic={this.handleStartTopicRequest}
onLogout={this.handleLogout}
onDeleteAccount={this.handleDeleteAccount}
@@ -2364,6 +2680,7 @@ class TinodeWeb extends React.Component {
viewportWidth={this.state.viewportWidth}
viewportHeight={this.state.viewportHeight}
topic={this.state.topicSelected}
jumpToSeq={this.state.sidePanelParams && this.state.sidePanelParams.jumpseq}
myUserId={this.state.myUserId}
// User public.fn.
myUserName={this.state.sidePanelTitle}
@@ -2377,6 +2694,12 @@ class TinodeWeb extends React.Component {
forwardMessage={this.state.forwardMessage}
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}
callSeq={this.state.callSeq}
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 FileProgress from './file-progress.jsx';
import { createFileDownloader } from '../lib/im-api.js';
import { bytesToHumanSize } from '../lib/strformat.js';
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
@@ -24,7 +25,11 @@ export default class Attachment extends React.Component {
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});
downloader.download(url, filename, mimetype,
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() {
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 (
<div className="alert-container">
<div className="incoming-call">
<div className="caller-card incoming pulse">
<div className="avatar-box">
<LetterTile
authorizeURL={this.props.tinode.authorizeURL}
avatar={this.state.avatar || true}
topic={this.props.topic}
title={this.state.fullName} />
</div>
<div className="caller-name">{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
<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 className={`call-fullscreen incoming-call-overlay ${surfaceClass} incoming`}>
<div className="call-ambient" />
{this.state.avatar &&
<div className="incoming-call-photo-blur" style={photoStyle} />}
<div className="call-peer-card">
<div className="avatar-box call-product-avatar">
<LetterTile
authorizeURL={this.props.tinode.authorizeURL}
avatar={this.state.avatar || true}
topic={this.props.topic}
title={this.state.fullName} />
</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>
<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>
);
}
+196 -37
View File
@@ -17,6 +17,14 @@ const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted';
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({
already_in_call: {
id: 'already_in_call',
@@ -44,7 +52,13 @@ class CallPanel extends React.PureComponent {
videoToggleInProgress: false,
// Indicates if the remote peer has informed us that their camera is on.
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 = {
audio: true,
@@ -89,6 +103,11 @@ class CallPanel extends React.PureComponent {
this.handleCloseClick = this.handleCloseClick.bind(this);
this.handleToggleCameraClick = this.handleToggleCameraClick.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.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
@@ -106,24 +125,29 @@ class CallPanel extends React.PureComponent {
this.bounds = {};
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.
return;
}
if (window.matchMedia('(width<=640px)')) {
if (window.matchMedia('(width<=640px)').matches) {
// No dragging on small screens.
return;
}
if (this.props.minimized && this.containerRef) {
event.stopPropagation();
this.containerRef.current.setPointerCapture(event.pointerId);
this.containerRef.current.style.cursor = 'grabbing';
this.bounds.left = this.containerRef.current.offsetLeft;
this.bounds.top = this.containerRef.current.offsetTop;
this.bounds.width = this.containerRef.current.offsetWidth;
this.bounds.height = this.containerRef.current.offsetHeight;
const el = this.containerRef.current;
el.setPointerCapture(event.pointerId);
el.style.cursor = 'grabbing';
this.bounds.left = el.offsetLeft;
this.bounds.top = el.offsetTop;
this.bounds.width = el.offsetWidth;
this.bounds.height = el.offsetHeight;
// Switch from right/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.maxY = parent.offsetHeight;
}
@@ -274,6 +298,7 @@ class CallPanel extends React.PureComponent {
CALL_ENDED_SOUND.currentTime = 0;
RING_SOUND.pause();
RING_SOUND.currentTime = 0;
this.stopElapsedTimer();
this.stopTracks(this.state.localStream);
this.stopTracks(this.state.remoteStream);
@@ -480,6 +505,27 @@ class CallPanel extends React.PureComponent {
// Remote video becomes available.
this.remoteRef.current.srcObject = 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) {
@@ -573,47 +619,104 @@ class CallPanel extends React.PureComponent {
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() {
if (!this.state.pc || !this.state.dataChannel) {
return;
}
const stream = this.state.localStream;
const t = stream.getVideoTracks()[0];
if (!t) {
this.setState({videoToggleInProgress: false});
return;
}
t.enabled = false;
t.stop();
stream.removeTrack(t);
this.state.dataChannel.send(VIDEO_MUTED_EVENT);
this.sendOnDataChannel(VIDEO_MUTED_EVENT);
this.setState({videoToggleInProgress: false});
}
unmuteVideo() {
if (!this.state.pc || !this.state.dataChannel) {
return;
// Swaps in a fresh local video track (new camera, or re-enabling after mute), hot-swapping
// the peer connection's sender if one exists, or adding one if the call never negotiated
// 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 })
.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];
// Remote track from new stream.
stream.removeTrack(track);
// Add this track to the existing local stream.
this.state.localStream.addTrack(track);
return sender.replaceTrack(track);
})
.then(_ => {
this.localRef.current.srcObject = this.state.localStream;
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
return this.replaceLocalVideoTrack(track);
})
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
.catch(err => this.handleGetUserMediaError(err))
.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() {
if (this.state.videoToggleInProgress) {
// Toggle currently in progress.
@@ -638,14 +741,44 @@ class CallPanel extends React.PureComponent {
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() {
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks();
const disabled = !this.state.pc || !this.state.dataChannel || !(audioTracks && audioTracks[0]);
const audioIcon = audioTracks && audioTracks[0] && audioTracks[0].enabled ? 'mic' : 'mic_off';
const videoIcon = videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live' ? 'videocam' : 'videocam_off';
// Mic/camera/speaker only need a local stream to exist -- toggling doesn't require a
// peer connection (e.g. caller previewing their camera while waiting for pickup).
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 pulseAnimation = this.state.waitingForPeer ? ' pulse' : '';
const connected = !!this.state.remoteStream;
let remoteActive = false;
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 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 (
<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}>
<video ref={this.localRef} autoPlay muted playsInline />
<div className="caller-name inactive">
@@ -698,12 +848,21 @@ class CallPanel extends React.PureComponent {
<button className="danger" onClick={this.handleCloseClick}>
<i className="material-icons">call_end</i>
</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>
</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>
</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>
);
+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() {
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.userAvatar || true;
const 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';
const fullDisplay = (this.props.isGroup && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
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 = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, (att, i) => {
@@ -234,6 +266,11 @@ class BaseChatMessage extends React.PureComponent {
return (
<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 ?
<div className="avatar-box">
{fullDisplay ?
@@ -245,6 +282,8 @@ class BaseChatMessage extends React.PureComponent {
null}
</div> :
null}
{isFailed && failReason ?
<i className="material-icons red message-fail-icon">error</i> : null}
<div>
<div className={bubbleClass}>
<div className="content-meta">
@@ -259,7 +298,7 @@ class BaseChatMessage extends React.PureComponent {
received={this.props.received} />
: null}
</div>
{this.props.showContextMenu ?
{this.props.showContextMenu && !this.props.selectMode ?
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
@@ -267,12 +306,15 @@ class BaseChatMessage extends React.PureComponent {
</span> : null
}
</div>
{failReason}
{fullDisplay ?
<div className="author">
{this.props.userName ||
<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>
}
{this.props.senderRole == 'owner' ? <em className="role-badge owner">群主</em> :
this.props.senderRole == 'admin' ? <em className="role-badge admin">管理员</em> : null}
</div>
: null
}
+7 -5
View File
@@ -9,6 +9,7 @@ import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
@@ -35,8 +36,8 @@ class ContactList extends React.PureComponent {
let contactsCount = 0;
if (this.props.showSelfTopic) {
const selected = showCheckmark ?
(this.props.topicSelected.indexOf('slf') > -1) :
(this.props.topicSelected === 'slf');
this.props.topicSelected.some(Tinode.isSelfTopicName) :
Tinode.isSelfTopicName(this.props.topicSelected);
contactNodes.push(
<Contact
tinode={this.props.tinode}
@@ -78,8 +79,9 @@ class ContactList extends React.PureComponent {
if (c.private && c.private.comment) {
filterOn.push(('' + c.private.comment).toLowerCase());
}
if (c.public && c.public.fn) {
filterOn.push(('' + c.public.fn).toLowerCase());
const displayName = topicDisplayName(key, c.public, null);
if (displayName) {
filterOn.push(('' + displayName).toLowerCase());
}
if (!this.props.filterFunc(this.props.filter, filterOn)) {
return;
@@ -124,7 +126,7 @@ class ContactList extends React.PureComponent {
contactNodes.push(
<Contact
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)}
comment={comment}
preview={preview}
+113
View File
@@ -1,8 +1,24 @@
// Context Menu: popup/dropdown menu.
import React from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { Drafty } from 'tinode-sdk';
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({
info: {
@@ -226,6 +242,33 @@ class ContextMenu extends React.Component {
title: formatMessage(messages.forward),
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': {
id: 'menu_item_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.
retryMessage(params, errorHandler) {
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,
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { createChatUploader } from '../lib/im-api.js';
import { truncateString } from '../lib/strformat.js';
export default class NewTopicGroup extends React.PureComponent {
@@ -69,7 +70,9 @@ export default class NewTopicGroup extends React.PureComponent {
let {mime, blob} = image;
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
// 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)
.then(url => this.setState({imageUrl: url}))
.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) {
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.
if (filePasted(e,
file => { this.props.onAttachImage(file); },
file => { this.props.onAttachFile(file); },
file => { if (this.props.onAttachImage) { this.props.onAttachImage(file); } },
file => { if (this.props.onAttachFile) { this.props.onAttachFile(file); } },
this.props.onError)) {
// If a file was pasted, don't paste base64 data into input field.
@@ -156,7 +159,7 @@ class SendMessage extends React.PureComponent {
}
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]);
}
// Clear the value so the same file can be uploaded again.
@@ -164,7 +167,7 @@ class SendMessage extends React.PureComponent {
}
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]);
}
// Clear the value so the same file can be uploaded again.
@@ -172,22 +175,30 @@ class SendMessage extends React.PureComponent {
}
handleDropAttach(files) {
if (files && files.length > 0) {
if (files && files.length > 0 && this.props.onAttachFile) {
this.props.onAttachFile(files[0]);
}
}
handleAttachAudio(url, preview, duration) {
this.setState({audioRec: false});
this.props.onAttachAudio(url, preview, duration);
if (this.props.onAttachAudio) {
this.props.onAttachAudio(url, preview, duration);
}
}
handleSend(e) {
e.preventDefault();
const message = this.state.message.trim();
if (message || this.props.acceptBlank || this.props.noInput) {
this.props.onSendMessage(message);
this.setState({message: ''});
const result = this.props.onSendMessage(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,
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { createChatUploader } from '../lib/im-api.js';
import { truncateString } from '../lib/strformat.js';
import { arrayEqual, theCard } from '../lib/utils.js';
@@ -149,7 +150,9 @@ class TopicDescEdit extends React.Component {
let {mime, blob} = image;
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
// 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)
.then(url => this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url)))
.catch(err => this.props.onError(err.message, 'err'));