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:
co-authored by
Claude Sonnet 5
parent
ba8f48ee83
commit
251c9de87c
@@ -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);
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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 || (() => {})} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')} /></>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {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;
|
||||
}
|
||||
|
||||
@@ -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('')} /></>;
|
||||
}
|
||||
|
||||
@@ -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('')} /></>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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: ''});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user