Compare commits
11
Commits
ac779ef31b
...
251c9de87c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251c9de87c | ||
|
|
ba8f48ee83 | ||
|
|
ca5e74ee98 | ||
|
|
111d9040a7 | ||
|
|
85ac8ce43b | ||
|
|
14e1e6b549 | ||
|
|
70a9ab8996 | ||
|
|
d24b297685 | ||
|
|
7bf3fa71d6 | ||
|
|
ac9d61eaad | ||
|
|
ece2aa0e87 |
@@ -1,13 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||
"comments": false,
|
||||
"plugins": [
|
||||
[
|
||||
"formatjs",
|
||||
{
|
||||
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
|
||||
"ast": true
|
||||
}
|
||||
]
|
||||
]
|
||||
"comments": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Deploy Webapp (Blue-Green)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像tag,留空则使用commit短哈希'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 确定镜像tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.image_tag }}"
|
||||
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 构建镜像
|
||||
run: |
|
||||
docker build -t im-webapp:${{ steps.vars.outputs.tag }} \
|
||||
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" .
|
||||
|
||||
- name: 蓝绿部署
|
||||
run: |
|
||||
IMAGE=im-webapp:${{ steps.vars.outputs.tag }} /opt/apps/im-webapp/deploy.sh
|
||||
@@ -2,3 +2,6 @@
|
||||
node_modules
|
||||
src/i18n/srci/
|
||||
webapp.code-workspace.code-workspace
|
||||
|
||||
# Local developer overrides — never commit real keys.
|
||||
.env.local
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# ---- Stage 1: build static assets ----
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
# TINODE_API_KEY is read by webpack.config.js (via DefinePlugin) at build time.
|
||||
ARG TINODE_API_KEY=
|
||||
ENV TINODE_API_KEY=$TINODE_API_KEY
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: serve with nginx ----
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY --from=build /app/umd ./umd
|
||||
COPY --from=build /app/css/base.min.css ./css/base.min.css
|
||||
COPY --from=build /app/index.html ./index.html
|
||||
COPY --from=build /app/index-dev.html ./index-dev.html
|
||||
COPY --from=build /app/manifest.json ./manifest.json
|
||||
COPY --from=build /app/service-worker.js ./service-worker.js
|
||||
COPY --from=build /app/version.js ./version.js
|
||||
COPY --from=build /app/img ./img
|
||||
COPY --from=build /app/audio ./audio
|
||||
# Push notifications are not configured for this deployment yet.
|
||||
RUN echo "" > ./firebase-init.js
|
||||
|
||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null http://localhost/ || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
+10336
-43
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Tinode webapp uses hash-based client-side routing, so any unknown path
|
||||
# can safely fall back to index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Filenames here aren't content-hashed, so a long/immutable cache would keep
|
||||
# serving a stale bundle after every deploy. Force revalidation instead:
|
||||
# cheap 304s when unchanged, immediate pickup of new content when not.
|
||||
location /umd/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location /img/ {
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
}
|
||||
Generated
+3251
-2467
File diff suppressed because it is too large
Load Diff
+21
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.25.2",
|
||||
"version": "0.25.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tinode/webapp.git"
|
||||
@@ -24,6 +24,9 @@
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
],
|
||||
"overrides": {
|
||||
"@babel/core": "^8.0.0"
|
||||
},
|
||||
"files": [
|
||||
"umd/*.js",
|
||||
"umd/*.map",
|
||||
@@ -75,30 +78,38 @@
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^11.0.0",
|
||||
"firebase": "^12.15.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"libphonenumber-js": "^1.10.40",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"qrcodejs": "^1.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-intl": "^7.0.0",
|
||||
"tinode-sdk": "^0.25.2",
|
||||
"tinode-sdk": "^0.25.3",
|
||||
"webm-duration-fix": "^1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.10",
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"@babel/preset-react": "^7.22.5",
|
||||
"@babel/core": "^8.0.0",
|
||||
"@babel/preset-env": "^8.0.0",
|
||||
"@babel/preset-react": "^8.0.0",
|
||||
"@formatjs/cli": "^6.1.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-formatjs": "^10.5.3",
|
||||
"babel-plugin-formatjs": "^11.0.0",
|
||||
"browserslist": "^4.21.10",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"cssnano": "^7.0.6",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cssnano": "^8.0.0",
|
||||
"glob": "^13.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"postcss": "^8.4.27",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^6.0.0"
|
||||
"webpack-cli": "^7.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@firebase/util@1.15.1": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"protobufjs@7.6.5": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ const i18n = {
|
||||
'new_message': "새로운 메시지",
|
||||
'new_chat': "새로운 채팅",
|
||||
},
|
||||
'pt': {
|
||||
'new_message': "Nova Mensagem",
|
||||
'new_chat': "Nova Conversa",
|
||||
},
|
||||
'ro': {
|
||||
'new_message': "Mesaj nou",
|
||||
'new_chat': "Chat nou",
|
||||
|
||||
+8
-3
@@ -1,10 +1,15 @@
|
||||
import { PACKAGE_VERSION } from './version.js';
|
||||
|
||||
// Version-independent name of this application, used for Firebase app name.
|
||||
export const BASE_APP_NAME = 'TinodeWeb';
|
||||
// Name of this application, used in the User-Agent.
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.24');
|
||||
export const APP_NAME = BASE_APP_NAME + '/' + (PACKAGE_VERSION || '0.25');
|
||||
|
||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
||||
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own.
|
||||
// Injected at build time via webpack DefinePlugin from the TINODE_API_KEY env var
|
||||
// (see webpack.config.js and .env.local). Falls back to the public sample key when unset,
|
||||
// which only works against a backend still using the matching sample API_KEY_SALT.
|
||||
export const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
|
||||
// The array of possible hosts to connect to.
|
||||
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1966
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -71,7 +71,8 @@ if (rtl.includes(baseLanguage)) {
|
||||
const root = createRoot(document.getElementById('mountPoint'));
|
||||
messageLoader[htmlLang]().then(messages =>
|
||||
root.render(
|
||||
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
|
||||
<TinodeWeb />
|
||||
</IntlProvider>
|
||||
React.createElement(IntlProvider,
|
||||
{ locale: language, messages: messages, textComponent: React.Fragment },
|
||||
React.createElement(TinodeWeb, null)
|
||||
)
|
||||
));
|
||||
|
||||
+22
-11
@@ -166,9 +166,15 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
// Generate comment for unknown element.
|
||||
let body = values;
|
||||
if (!Array.isArray(values) || !values.join('').trim()) {
|
||||
body = [<span key="x1" className="gray">{this.formatMessage(messages.drafty_unknown)}</span>];
|
||||
body = [React.createElement('span',
|
||||
{key: 'x1', className: 'gray'},
|
||||
this.formatMessage(messages.drafty_unknown)
|
||||
)];
|
||||
}
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' '].concat(body);
|
||||
values = [React.createElement('i',
|
||||
{key: 'x0', className: 'material-icons gray'},
|
||||
'extension'
|
||||
), ' '].concat(body);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -276,7 +282,8 @@ export function previewFormatter(style, data, values, key) {
|
||||
case 'AU':
|
||||
// Voicemail as '[mic] 0:00'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="au" className="material-icons">mic</i>, ' ', secondsToTime(data.duration/1000)];
|
||||
values = [React.createElement('i', {key: 'au', className: 'material-icons'}, 'mic'),
|
||||
' ', secondsToTime(data.duration/1000)];
|
||||
break;
|
||||
case 'BR':
|
||||
// Replace new line with a space.
|
||||
@@ -295,7 +302,8 @@ export function previewFormatter(style, data, values, key) {
|
||||
case 'IM':
|
||||
// Replace image with '[icon] Image'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">photo</i>, ' ', this.formatMessage(messages.drafty_image)];
|
||||
values = [React.createElement('i', {key: 'im', className: 'material-icons'}, 'photo'),
|
||||
' ', this.formatMessage(messages.drafty_image)];
|
||||
break;
|
||||
case 'BN':
|
||||
el = 'span';
|
||||
@@ -303,7 +311,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
break;
|
||||
case 'FM':
|
||||
el = React.Fragment;
|
||||
values = [<i key="fm" className="material-icons">dashboard</i>,
|
||||
values = [React.createElement('i', {key: 'fm', className: 'material-icons'}, 'dashboard'),
|
||||
this.formatMessage(messages.drafty_form)].concat(' ', values || []);
|
||||
break;
|
||||
case 'RW':
|
||||
@@ -320,7 +328,8 @@ export function previewFormatter(style, data, values, key) {
|
||||
delete data.ref;
|
||||
}
|
||||
el = React.Fragment;
|
||||
values = [<i key="ex" className="material-icons">attachment</i>, ' ', this.formatMessage(messages.drafty_attachment)];
|
||||
values = [React.createElement('i', {key: 'ex', className: 'material-icons'}, 'attachment'),
|
||||
' ', this.formatMessage(messages.drafty_attachment)];
|
||||
break;
|
||||
case 'VC':
|
||||
el = CallStatus;
|
||||
@@ -339,19 +348,21 @@ export function previewFormatter(style, data, values, key) {
|
||||
case 'TC':
|
||||
// TheCard
|
||||
el = React.Fragment;
|
||||
values = [<i key="tc" className="material-icons">contact_mail</i>, ' ',
|
||||
values = [React.createElement('i', {key: 'tc', className: 'material-icons'}, 'contact_mail'), ' ',
|
||||
TheCard.getFn(data) || this.formatMessage(messages.drafty_unknown)];
|
||||
break;
|
||||
case 'VD':
|
||||
// Replace image with '[icon] Video'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">play_circle_outline</i>, ' ', this.formatMessage(messages.drafty_video)];
|
||||
values = [React.createElement('i', {key: 'im', className: 'material-icons'}, 'play_circle_outline'),
|
||||
' ', this.formatMessage(messages.drafty_video)];
|
||||
break;
|
||||
default:
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
|
||||
values = [React.createElement('i', {key: 'x0', className: 'material-icons gray'}, 'extension'),
|
||||
' ', this.formatMessage(messages.drafty_unknown)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -449,7 +460,7 @@ function quoteFormatter(style, data, values, key) {
|
||||
delete data.ref;
|
||||
}
|
||||
el = React.Fragment;
|
||||
values = [<i key="ex" className="material-icons">attachment</i>,
|
||||
values = [React.createElement('i', {key: 'ex', className: 'material-icons'}, 'attachment'),
|
||||
shortenFileName(fname, 16) || this.formatMessage(messages.drafty_attachment)];
|
||||
break;
|
||||
}
|
||||
@@ -551,7 +562,7 @@ export function replyFormatter(style, data, values, key, stack) {
|
||||
if (stack.includes('QQ')) {
|
||||
// Quote inside quote when forwarding a message.
|
||||
return React.createElement('span', {key: key},
|
||||
[<i key="qq" className="material-icons">format_quote</i>, ' ']);
|
||||
[React.createElement('i', {key: 'qq', className: 'material-icons'}, 'format_quote'), ' ']);
|
||||
}
|
||||
|
||||
const attr = Drafty.attrValue('QQ', data) || {};
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.25.2";
|
||||
export const PACKAGE_VERSION = "0.25.3";
|
||||
|
||||
@@ -130,6 +130,18 @@ class AccSecurityView extends React.Component {
|
||||
</span>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column security-product-links">
|
||||
<button type="button" className="flat-button" onClick={() => this.props.onNavigate('realname')}>
|
||||
<i className="material-icons">verified_user</i><span>实名认证</span><i className="material-icons trailing">chevron_right</i>
|
||||
</button>
|
||||
<button type="button" className="flat-button" onClick={() => this.props.onNavigate('identity-check')}>
|
||||
<i className="material-icons">phonelink_lock</i><span>身份验证</span><i className="material-icons trailing">chevron_right</i>
|
||||
</button>
|
||||
<button type="button" className="flat-button" onClick={() => this.props.onNavigate('independent-password')}>
|
||||
<i className="material-icons">password</i><span>设置独立密码</span><i className="material-icons trailing">chevron_right</i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
|
||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
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}$/;
|
||||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||||
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
|
||||
function Icon({children, className = ''}) {
|
||||
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
|
||||
}
|
||||
|
||||
function AppMark({tenantName}) {
|
||||
return <div className="auth-brand">
|
||||
<div className="auth-logo"><span>J</span></div>
|
||||
<h1>{tenantName || '即时通讯'}</h1>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Header({title, back = 'tenant'}) {
|
||||
return <header className="auth-header">
|
||||
<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>
|
||||
<span className="auth-header-spacer" />
|
||||
</header>;
|
||||
}
|
||||
|
||||
function Field({icon, action, error, className = '', ...props}) {
|
||||
return <div className={'auth-field-wrap ' + className}>
|
||||
<div className={'auth-field' + (error ? ' invalid' : '')}>
|
||||
{icon ? <Icon className="auth-field-icon">{icon}</Icon> : null}
|
||||
<input {...props} />
|
||||
{action}
|
||||
</div>
|
||||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PasswordField(props) {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
return <Field {...props} type={visible ? 'text' : 'password'}
|
||||
action={<button type="button" className="auth-inline-icon" onClick={() => setVisible(!visible)}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
|
||||
}
|
||||
|
||||
function Agreement({checked, onChange, prefix = '同意捷信密使'}) {
|
||||
return <label className="auth-agreement">
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
|
||||
<span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
|
||||
</label>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="auth-toast" role="status"><Icon>check_circle</Icon>{message}</div> : null;
|
||||
}
|
||||
|
||||
// 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 = async e => {
|
||||
e.preventDefault();
|
||||
const value = code.trim();
|
||||
if (!value || value.length > 64) {
|
||||
setError(value ? '企业码不能超过 64 个字符' : '请输入企业码');
|
||||
return;
|
||||
}
|
||||
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">
|
||||
<button className="auth-top-link" type="button" onClick={() => navigate('enterprise-register')}>企业注册</button>
|
||||
<form className="auth-landing-form" onSubmit={submit}>
|
||||
<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() || busy}>{busy ? '进入中…' : '进入企业'}</button>
|
||||
</form>
|
||||
<button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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 || loginDisabled) return;
|
||||
onLoginRequest(account.trim(), password);
|
||||
};
|
||||
|
||||
return <div className="auth-landing auth-login-page">
|
||||
<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} />
|
||||
<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)}
|
||||
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 || loginDisabled}>
|
||||
{loginDisabled ? '登录中…' : '登录'}
|
||||
</button>
|
||||
<p className="auth-switch">没有账户? <button type="button" onClick={() => navigate('register')}>马上注册</button></p>
|
||||
</form>
|
||||
<Agreement checked={agreed} onChange={setAgreed} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EnterpriseRegister() {
|
||||
const [creditCode, setCreditCode] = React.useState('');
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [file, setFile] = React.useState(null);
|
||||
const [errors, setErrors] = React.useState({});
|
||||
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]);
|
||||
|
||||
const chooseFile = e => {
|
||||
const selected = e.target.files[0];
|
||||
if (!selected) return;
|
||||
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
|
||||
setErrors({...errors, file: '支持 JPG、PNG、WebP、PDF,文件不超过 10MB'});
|
||||
return;
|
||||
}
|
||||
if (file) URL.revokeObjectURL(file.url);
|
||||
setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected), blob: selected});
|
||||
setErrors({...errors, file: ''});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li>
|
||||
<li><span>2</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} />
|
||||
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
|
||||
|
||||
<label className="auth-label">上传营业执照 <span>选填</span></label>
|
||||
<label className={'auth-upload' + (errors.file ? ' invalid' : '')}>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" onChange={chooseFile} />
|
||||
{file && file.type !== 'application/pdf' ? <img src={file.url} alt="营业执照预览" /> : <Icon>add_photo_alternate</Icon>}
|
||||
<strong>{file ? file.name : '点击上传营业执照'}</strong>
|
||||
<small>支持 JPG、PNG、WebP、PDF,不超过 10MB</small>
|
||||
</label>
|
||||
{file ? <button type="button" className="auth-remove-file" onClick={() => { URL.revokeObjectURL(file.url); setFile(null); }}>移除文件</button> : null}
|
||||
{errors.file ? <div className="auth-field-error">{errors.file}</div> : null}
|
||||
|
||||
<label className="auth-label">企业邮箱 <span>选填</span></label>
|
||||
<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 || 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 [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), blob: file}))));
|
||||
};
|
||||
const submit = async e => {
|
||||
e.preventDefault();
|
||||
const length = description.trim().length;
|
||||
if (length < 10 || length > 300) {
|
||||
setError('问题描述需为 10 至 300 个字符');
|
||||
return;
|
||||
}
|
||||
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="意见反馈" />
|
||||
<main className="auth-content auth-content-narrow">
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<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">图片({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>photo_camera</Icon><span>点击上传</span></label> : null}
|
||||
</div>
|
||||
<button className="auth-primary" type="submit" disabled={description.trim().length < 10 || submitting}>{submitting ? '提交中…' : '提交'}</button>
|
||||
</form>
|
||||
</main>
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
// 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(s => s - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [seconds]);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
// 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 [submitting, setSubmitting] = React.useState(false);
|
||||
const change = key => e => setForm({...form, [key]: e.target.value});
|
||||
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 (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;
|
||||
|
||||
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">
|
||||
<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={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 || submitting}>
|
||||
{submitting ? '注册中…' : '注册'}
|
||||
</button>
|
||||
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
|
||||
</form>
|
||||
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
|
||||
</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 = async e => {
|
||||
e.preventDefault();
|
||||
if (!PHONE_RE.test(form.phone)) return setError('请输入 11 位手机号');
|
||||
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('两次输入的密码不一致');
|
||||
|
||||
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={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 位" /></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 || 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 [phoneMask, setPhoneMask] = React.useState('');
|
||||
const [challengeId, setChallengeId] = React.useState('');
|
||||
const [code, setCode] = React.useState('');
|
||||
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={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>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 [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;
|
||||
}
|
||||
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={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={!editable || busy}>
|
||||
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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>根据国家法律法规要求,您需先进行实名认证才能使用该项 APP</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 = async e => {
|
||||
e.preventDefault();
|
||||
if (invalid.includes(pin)) { setError('请勿使用连续或重复数字'); return; }
|
||||
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'} />
|
||||
<main className="auth-pin-content">
|
||||
<h1>设置独立密码</h1>
|
||||
<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 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
|
||||
</main>
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 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 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 authenticated={authenticated} />;
|
||||
case 'realname-reminder': return <RealnameReminder />;
|
||||
case 'independent-password': return <IndependentPassword authenticated={authenticated} />;
|
||||
default: return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
|
||||
export const CALL_EXPERIENCE_VIEWS = [
|
||||
'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active',
|
||||
'audio-call-muted', 'audio-call-speaker', 'video-call-ringing',
|
||||
'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off',
|
||||
'call-minimized'
|
||||
];
|
||||
|
||||
const PEER = {name: '安妮', avatar: 'img/bkg/p09.jpg'};
|
||||
|
||||
const CALL_RECORDS = [
|
||||
{id: 1, type: 'video', direction: 'outgoing', state: 'cancelled', label: '已取消', time: '今天 10:24'},
|
||||
{id: 2, type: 'audio', direction: 'incoming', state: 'declined', label: '已拒绝', time: '昨天 21:03'},
|
||||
{id: 3, type: 'video', direction: 'incoming', state: 'missed', label: '未接听', time: '昨天 18:42'},
|
||||
{id: 4, type: 'audio', direction: 'outgoing', state: 'busy', label: '对方忙线', time: '星期一 15:10'},
|
||||
{id: 5, type: 'audio', direction: 'outgoing', state: 'finished', label: '通话时长 12:36', time: '8月20日'}
|
||||
];
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 1800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="call-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
function CallAvatar({small = false}) {
|
||||
return <span className={'call-product-avatar' + (small ? ' small' : '')}><img src={PEER.avatar} alt="" /></span>;
|
||||
}
|
||||
|
||||
function CallHeader({onMinimize, light = false}) {
|
||||
return <header className={'call-product-header' + (light ? ' light' : '')}>
|
||||
<button type="button" onClick={onMinimize} aria-label="最小化通话"><Icon>keyboard_arrow_down</Icon></button>
|
||||
<span>端到端加密通话</span>
|
||||
<button type="button" aria-label="更多通话设置"><Icon>more_vert</Icon></button>
|
||||
</header>;
|
||||
}
|
||||
|
||||
function Control({icon, label, active, danger, onClick}) {
|
||||
return <button type="button" className={'call-control' + (active ? ' active' : '') + (danger ? ' danger' : '')} onClick={onClick}><span><Icon>{icon}</Icon></span><small>{label}</small></button>;
|
||||
}
|
||||
|
||||
function CallHistory() {
|
||||
return <div className="call-history-page">
|
||||
<header><button type="button" onClick={() => navigate('chat-standard')} aria-label="返回会话"><Icon>arrow_back_ios_new</Icon></button><h1>安妮</h1><button type="button" onClick={() => navigate('friend-profile')} aria-label="个人资料"><Icon>person_outline</Icon></button></header>
|
||||
<main><p className="chat-time">最近通话</p>{CALL_RECORDS.map(item => <button type="button" className={'call-record ' + item.state} key={item.id} onClick={() => navigate(item.type === 'audio' ? 'audio-call-ringing' : 'video-call-ringing')}><Icon>{item.type === 'audio' ? 'call' : 'videocam'}</Icon><span><strong>{item.type === 'audio' ? '语音通话' : '视频通话'}</strong><small>{item.label}</small></span><time>{item.time}</time><Icon>{item.direction === 'incoming' ? 'call_received' : 'call_made'}</Icon></button>)}</main>
|
||||
<footer><button type="button" onClick={() => navigate('audio-call-ringing')}><Icon>call</Icon><span>语音通话</span></button><button type="button" onClick={() => navigate('video-call-ringing')}><Icon>videocam</Icon><span>视频通话</span></button></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function IncomingCall({onToast}) {
|
||||
return <div className="call-fullscreen call-audio-surface incoming">
|
||||
<div className="call-ambient" />
|
||||
<CallHeader onMinimize={() => navigate('call-minimized')} />
|
||||
<main className="call-peer-card"><CallAvatar /><h1>{PEER.name}</h1><p>邀请你进行视频通话</p><span className="call-ringing-dots"><i /><i /><i /></span></main>
|
||||
<footer className="call-incoming-actions"><Control icon="call_end" label="拒绝" danger onClick={() => {onToast('已拒绝视频通话'); window.setTimeout(() => navigate('call-history'), 600);}} /><Control icon="videocam" label="接听" active onClick={() => navigate('video-call-active')} /></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
return `${mins}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function AudioCall({view, onToast}) {
|
||||
const ringing = view === 'audio-call-ringing';
|
||||
const [seconds, setSeconds] = React.useState(ringing ? 0 : 68);
|
||||
const [mic, setMic] = React.useState(view !== 'audio-call-muted');
|
||||
const [speaker, setSpeaker] = React.useState(view === 'audio-call-speaker');
|
||||
React.useEffect(() => {
|
||||
if (ringing) return undefined;
|
||||
const timer = window.setInterval(() => setSeconds(value => value + 1), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [ringing]);
|
||||
const hangup = () => {onToast(ringing ? '已取消呼叫' : `通话结束 ${formatDuration(seconds)}`); window.setTimeout(() => navigate('call-history'), 650);};
|
||||
return <div className="call-fullscreen call-audio-surface">
|
||||
<div className="call-ambient" />
|
||||
<CallHeader onMinimize={() => navigate('call-minimized')} />
|
||||
<main className="call-peer-card"><CallAvatar /><h1>{PEER.name}</h1><p>{ringing ? '正在等待对方接听…' : formatDuration(seconds)}</p>{!ringing ? <small className="call-network"><i />网络良好</small> : <span className="call-ringing-dots"><i /><i /><i /></span>}</main>
|
||||
{ringing ? <footer className="call-single-action"><Control icon="call_end" label="取消" danger onClick={hangup} /></footer> : <footer className="call-active-controls"><Control icon={mic ? 'mic' : 'mic_off'} label={mic ? '麦克风' : '麦克风已关'} active={mic} onClick={() => {setMic(!mic); onToast(mic ? '麦克风已关闭' : '麦克风已开启');}} /><Control icon={speaker ? 'volume_up' : 'hearing'} label={speaker ? '扬声器' : '听筒'} active={speaker} onClick={() => {setSpeaker(!speaker); onToast(speaker ? '已切换到听筒' : '扬声器已开启');}} /><Control icon="call_end" label="挂断" danger onClick={hangup} /></footer>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VideoBackdrop({cameraOff, peerOff}) {
|
||||
if (peerOff) return <div className="call-video-off"><CallAvatar /><strong>对方摄像头已关闭</strong><small>语音通话仍在继续</small></div>;
|
||||
if (cameraOff) return <div className="call-video-off local"><CallAvatar /><strong>摄像头已关闭</strong></div>;
|
||||
return <img className="call-video-backdrop" src="img/bkg/w06.jpg" alt="视频通话画面" />;
|
||||
}
|
||||
|
||||
function VideoCall({view, onToast}) {
|
||||
const ringing = view === 'video-call-ringing' || view === 'video-call-camera-off';
|
||||
const [camera, setCamera] = React.useState(view !== 'video-call-camera-off');
|
||||
const [mic, setMic] = React.useState(true);
|
||||
const [seconds, setSeconds] = React.useState(ringing ? 0 : 128);
|
||||
const peerOff = view === 'video-call-peer-camera-off';
|
||||
React.useEffect(() => {
|
||||
if (ringing) return undefined;
|
||||
const timer = window.setInterval(() => setSeconds(value => value + 1), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [ringing]);
|
||||
const hangup = () => {onToast(ringing ? '已取消视频呼叫' : `视频通话结束 ${formatDuration(seconds)}`); window.setTimeout(() => navigate('call-history'), 650);};
|
||||
return <div className="call-fullscreen call-video-surface">
|
||||
<VideoBackdrop cameraOff={ringing && !camera} peerOff={peerOff} />
|
||||
<div className="call-video-shade" />
|
||||
<CallHeader light onMinimize={() => navigate('call-minimized')} />
|
||||
<div className="call-video-status"><h1>{PEER.name}</h1><p>{ringing ? '正在等待对方接听…' : formatDuration(seconds)}</p></div>
|
||||
{!ringing ? <button type="button" className="call-local-preview" aria-label="本地视频预览"><img src={camera ? 'img/bkg/p05.jpg' : PEER.avatar} alt="" />{!camera ? <Icon>videocam_off</Icon> : null}</button> : null}
|
||||
<footer className="call-video-controls"><Control icon="flip_camera_android" label="翻转" onClick={() => onToast('已切换摄像头')} /><Control icon={camera ? 'videocam' : 'videocam_off'} label={camera ? '摄像头' : '摄像头已关'} active={camera} onClick={() => {setCamera(!camera); onToast(camera ? '摄像头已关闭' : '摄像头已开启');}} /><Control icon="call_end" label={ringing ? '取消' : '挂断'} danger onClick={hangup} />{!ringing ? <Control icon={mic ? 'mic' : 'mic_off'} label={mic ? '麦克风' : '麦克风已关'} active={mic} onClick={() => setMic(!mic)} /> : null}</footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MinimizedCall({onToast}) {
|
||||
const [position, setPosition] = React.useState(() => ({x: Math.max(16, window.innerWidth - 134), y: 168}));
|
||||
const drag = React.useRef(null);
|
||||
const onPointerDown = event => {drag.current = {x: event.clientX - position.x, y: event.clientY - position.y}; event.currentTarget.setPointerCapture(event.pointerId);};
|
||||
const onPointerMove = event => {if (!drag.current) return; setPosition({x: Math.max(8, Math.min(window.innerWidth - 132, event.clientX - drag.current.x)), y: Math.max(70, Math.min(window.innerHeight - 190, event.clientY - drag.current.y))});};
|
||||
return <div className="call-minimized-page"><header className="call-chat-preview-header"><button type="button" onClick={() => navigate('messages-home')}><Icon>arrow_back_ios_new</Icon></button><div><strong>安妮</strong><small>在线</small></div><button type="button"><Icon>more_vert</Icon></button></header><main><p className="chat-time">今天 10:24</p><div className="call-chat-preview-message">下午的评审材料我已经发到群里了。</div></main><button type="button" className="call-floating-window" style={{transform: `translate(${position.x}px, ${position.y}px)`}} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={() => {drag.current = null;}} onClick={() => navigate('video-call-active')} aria-label="返回全屏通话"><img src="img/bkg/w06.jpg" alt="视频通话悬浮画面" /><span><i />02:18</span></button><footer className="call-chat-composer"><input placeholder="输入消息" /><button type="button"><Icon>add_circle_outline</Icon></button></footer><button type="button" className="call-mini-hangup" onClick={() => {onToast('通话已结束'); window.setTimeout(() => navigate('call-history'), 600);}} aria-label="挂断通话"><Icon>call_end</Icon></button></div>;
|
||||
}
|
||||
|
||||
export default function CallExperienceView({view}) {
|
||||
const [toast, setToast] = React.useState('');
|
||||
let content;
|
||||
if (view === 'call-history') content = <CallHistory />;
|
||||
else if (view === 'call-incoming') content = <IncomingCall onToast={setToast} />;
|
||||
else if (view.startsWith('audio-call')) content = <AudioCall view={view} onToast={setToast} />;
|
||||
else if (view === 'call-minimized') content = <MinimizedCall onToast={setToast} />;
|
||||
else content = <VideoCall view={view} onToast={setToast} />;
|
||||
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
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';
|
||||
|
||||
// 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',
|
||||
'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings',
|
||||
'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm',
|
||||
'clear-chat-confirm'
|
||||
];
|
||||
|
||||
// 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', 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}) {
|
||||
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}) {
|
||||
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, 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>;
|
||||
}
|
||||
|
||||
// `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 || !payload) return;
|
||||
ref.current.innerHTML = '';
|
||||
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({myUserId, onTopicSelected}) {
|
||||
const [query, setQuery] = React.useState('');
|
||||
// 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: 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'}
|
||||
];
|
||||
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([]);
|
||||
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>;
|
||||
}
|
||||
|
||||
// `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}) {
|
||||
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({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({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({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 [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([]);
|
||||
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, uid, tinode, onToast}) {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const config = {
|
||||
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];
|
||||
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, hashParams, myUserId, tinode, onTopicSelected, onOpenScanner}) {
|
||||
const [toast, setToast] = React.useState('');
|
||||
const uid = hashParams && hashParams.uid;
|
||||
let content;
|
||||
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'} onToast={setToast} onOpenScanner={onOpenScanner} />;
|
||||
else if (view === 'my-friend-qr') content = <QrPage 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" 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('')} /></>;
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Drafty, Tinode } from 'tinode-sdk';
|
||||
|
||||
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',
|
||||
'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
|
||||
'file-downloading', 'file-downloaded', 'history-search', 'history-files',
|
||||
'history-links', 'history-images', 'history-videos'
|
||||
];
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
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>; }
|
||||
|
||||
// -- 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 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>;
|
||||
}
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
// 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">{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', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
|
||||
|
||||
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 [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>;
|
||||
}
|
||||
|
||||
// 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({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, 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-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>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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} 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('')} /></>;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
|
||||
const VIEW_CONFIG = {
|
||||
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
|
||||
'chat-inbound-message': {relationship: 'pending_inbound', variant: 'inbound-message'},
|
||||
'chat-outbound-request': {relationship: 'pending_outbound', variant: 'outbound-request'},
|
||||
'chat-status': {relationship: 'friend', variant: 'status'},
|
||||
'chat-friend': {relationship: 'friend', variant: 'friend'},
|
||||
'chat-media': {relationship: 'friend', variant: 'media'},
|
||||
'chat-blocked': {relationship: 'blocked_by_peer', variant: 'blocked'},
|
||||
'chat-deleted': {relationship: 'none', variant: 'deleted'},
|
||||
'chat-standard': {relationship: 'friend', variant: 'standard'},
|
||||
'chat-attachments': {relationship: 'friend', variant: 'attachments'},
|
||||
'chat-mixed': {relationship: 'friend', variant: 'mixed'},
|
||||
'chat-contact-card': {relationship: 'friend', variant: 'contact-card'}
|
||||
};
|
||||
|
||||
const ATTACHMENTS = [
|
||||
{id: 'photo', icon: 'image', label: '照片'},
|
||||
{id: 'camera', icon: 'photo_camera', label: '拍摄'},
|
||||
{id: 'call', icon: 'call', label: '视频语音'},
|
||||
{id: 'card', icon: 'contact_page', label: '名片'},
|
||||
{id: 'file', icon: 'folder', label: '文件'},
|
||||
{id: 'favorite', icon: 'bookmark', label: '收藏'},
|
||||
{id: 'video', icon: 'videocam', label: '视频通话'}
|
||||
];
|
||||
|
||||
function Icon({children}) {
|
||||
return <i className="material-icons" aria-hidden="true">{children}</i>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="chat-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
function ChatAvatar({mine = false, peer = false}) {
|
||||
return <div className="chat-avatar"><img src={mine ? 'img/bkg/p05.jpg' : peer ? 'img/bkg/p09.jpg' : 'img/bkg/p14.jpg'} alt="" /></div>;
|
||||
}
|
||||
|
||||
function Status({value, failedReason}) {
|
||||
if (!value && !failedReason) return null;
|
||||
return <div className={'chat-message-status' + (failedReason ? ' failed' : '')}>
|
||||
{failedReason ? <Icon>error</Icon> : null}<span>{failedReason || value}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FileCard() {
|
||||
return <div className="chat-file-card">
|
||||
<div><strong>365天艾宾浩斯记忆曲线复习计划.pdf</strong><small>115KB</small></div>
|
||||
<span className="chat-file-icon">P</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ContactCard({onOpen}) {
|
||||
return <button type="button" className="chat-contact-card" onClick={onOpen}>
|
||||
<span><img src="img/bkg/p15.jpg" alt="" /><strong>栗子来了</strong></span>
|
||||
<small>个人名片 · 账号 QL1024</small>
|
||||
</button>;
|
||||
}
|
||||
|
||||
function VoiceMessage({duration = 6, playing = false}) {
|
||||
return <div className="chat-voice-message"><Icon>{playing ? 'graphic_eq' : 'volume_up'}</Icon><strong>{duration}″</strong><span /></div>;
|
||||
}
|
||||
|
||||
function ImageMessage({progress}) {
|
||||
return <div className="chat-image-message">
|
||||
<img src="img/bkg/p22.jpg" alt="会话图片" />
|
||||
{progress < 100 ? <div className="chat-upload-progress"><progress value={progress} max="100" /><strong>{progress}%</strong></div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Bubble({mine = false, children, status, failedReason, type = 'text', onOpenCard}) {
|
||||
return <div className={'chat-message-row' + (mine ? ' mine' : '')}>
|
||||
{!mine ? <ChatAvatar peer /> : null}
|
||||
<div className="chat-message-stack">
|
||||
{type === 'file' ? <FileCard /> : type === 'card' ? <ContactCard onOpen={onOpenCard} /> :
|
||||
<div className={'chat-bubble ' + type + (mine ? ' mine' : '')}>{children}</div>}
|
||||
<Status value={status} failedReason={failedReason} />
|
||||
</div>
|
||||
{mine ? <ChatAvatar mine /> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RelationNotice({children, action, onAction}) {
|
||||
return <div className="chat-relation-notice"><span>{children}</span>{action ? <button type="button" onClick={onAction}>{action}</button> : null}</div>;
|
||||
}
|
||||
|
||||
function ChatTimeline({variant, uploadProgress, sendStatus, onRetry, onOpenCard, onFriendAction}) {
|
||||
if (variant === 'inbound-request' || variant === 'inbound-message') {
|
||||
return <>
|
||||
<p className="chat-time">7月30日 11:40</p>
|
||||
<Bubble>{variant === 'inbound-request' ? '请求添加你为好友' : '我是祈万事安好'}</Bubble>
|
||||
<RelationNotice>以上是打招呼的消息</RelationNotice>
|
||||
<div className="chat-request-actions"><button type="button" onClick={() => onFriendAction('declined')}>拒绝</button><button type="button" className="primary" onClick={() => onFriendAction('accepted')}>通过申请</button></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (variant === 'outbound-request') {
|
||||
return <>
|
||||
<p className="chat-time">7月30日 11:40</p>
|
||||
<Bubble mine status="已发送">请求添加你为好友</Bubble>
|
||||
<RelationNotice>好友申请已发送,等待对方处理</RelationNotice>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (variant === 'status') {
|
||||
return <>
|
||||
<p className="chat-time">7月30日 11:40</p>
|
||||
<Bubble mine status={sendStatus}>我是祈万事安好</Bubble>
|
||||
<RelationNotice>你已添加安妮,现在可以开始聊天。</RelationNotice>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (variant === 'friend') {
|
||||
return <>
|
||||
<p className="chat-time">8月27日 11:11</p>
|
||||
<RelationNotice>你们已成为好友,现在可以开始聊天</RelationNotice>
|
||||
<Bubble>我们已成为好友,现在我们可以开始聊天</Bubble>
|
||||
<Bubble mine status="已读">你还好吗?</Bubble>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (variant === 'media') {
|
||||
return <>
|
||||
<p className="chat-time">8月27日 11:11</p>
|
||||
<RelationNotice>接雨彤创建群聊</RelationNotice>
|
||||
<Bubble mine status="已读">你还好吗?</Bubble>
|
||||
<Bubble mine type="image"><ImageMessage progress={uploadProgress} /></Bubble>
|
||||
<Bubble mine type="voice"><VoiceMessage duration={6} playing /></Bubble>
|
||||
<Bubble type="voice"><VoiceMessage duration={6} /></Bubble>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (variant === 'blocked') {
|
||||
return <>
|
||||
<p className="chat-time">7月30日 11:40</p>
|
||||
<Bubble mine status="已读">请求添加你为好友</Bubble>
|
||||
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
|
||||
<p className="chat-time">7月30日 13:00</p>
|
||||
<Bubble mine status="已读">这边在网上看见你的留言,你想了解什么课程了</Bubble>
|
||||
<Bubble>有课程辅导的老师?对新手友好吗?</Bubble>
|
||||
<Bubble mine failedReason="消息已发送,但被对方拒收了">这个不用担心特别好上手,学起来特别简单</Bubble>
|
||||
<Bubble mine failedReason="消息已发送,但被对方拒收了">拉黑我这么速度,真的牛</Bubble>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (variant === 'deleted') {
|
||||
return <>
|
||||
<p className="chat-time">7月30日 13:00</p>
|
||||
<Bubble mine status="已读">你好我是XXX推荐过来的,这边想了解课程是吗</Bubble>
|
||||
<Bubble>这边目前找到更好的课程,不考虑你们家,谢谢</Bubble>
|
||||
<Bubble mine status="已读">这是我们导师名片,你可以加一下,对比一下价格</Bubble>
|
||||
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
|
||||
<Bubble mine failedReason="发送失败,需重新添加好友">这么突然把我删了,真心累了</Bubble>
|
||||
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
|
||||
</>;
|
||||
}
|
||||
|
||||
const base = <>
|
||||
<p className="chat-time">7月30日 11:40</p>
|
||||
<Bubble mine status="已读">请求添加你为好友</Bubble>
|
||||
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
|
||||
<p className="chat-time">7月30日 13:00</p>
|
||||
<Bubble mine status="已读">你好,我是王珊珊,很高兴认识你,你怎么称呼</Bubble>
|
||||
<Bubble>你好,我是安妮,想跟你咨询一下设计课程,方便?</Bubble>
|
||||
</>;
|
||||
|
||||
if (variant === 'standard') {
|
||||
return <>{base}<Bubble mine status="未读">非常方便,这是我们的课程目录,你可以看看了解一下</Bubble><Bubble mine type="file" status="未读" /></>;
|
||||
}
|
||||
if (variant === 'mixed') {
|
||||
return <>{base}<Bubble mine type="file" status="已送达" /><Bubble type="card" onOpenCard={onOpenCard} /></>;
|
||||
}
|
||||
if (variant === 'contact-card') {
|
||||
return <>{base}<Bubble mine status="已读">这是我们导师名片,你可以加一下,有问题随时联系他</Bubble><Bubble mine type="card" status="已送达" onOpenCard={onOpenCard} /><Bubble>好的,非常感谢,那我这边就直接联系他吧</Bubble><Bubble mine status="已读">没问题的,有疑问随时沟通</Bubble></>;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function AttachmentPanel({onChoose}) {
|
||||
return <div className="chat-attachment-panel">
|
||||
{ATTACHMENTS.map(item => <button type="button" key={item.id} onClick={() => onChoose(item)}><span><Icon>{item.icon}</Icon></span><small>{item.label}</small></button>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Composer({relationship, attachmentOpen, setAttachmentOpen, onSend}) {
|
||||
const [draft, setDraft] = React.useState(sessionStorage.getItem('im-chat-draft') || '');
|
||||
const canSend = relationship === 'friend';
|
||||
const updateDraft = value => {
|
||||
setDraft(value);
|
||||
sessionStorage.setItem('im-chat-draft', value);
|
||||
};
|
||||
const submit = e => {
|
||||
e.preventDefault();
|
||||
if (!canSend || !draft.trim()) return;
|
||||
onSend(draft.trim());
|
||||
updateDraft('');
|
||||
};
|
||||
return <form className={'chat-composer' + (!canSend ? ' locked' : '')} onSubmit={submit}>
|
||||
<button type="button" aria-label="语音" disabled={!canSend} onClick={() => navigate('voice-ready')}><Icon>record_voice_over</Icon></button>
|
||||
<input value={draft} onChange={e => updateDraft(e.target.value)} placeholder={canSend ? '输入消息' : relationship === 'pending_inbound' ? '请先处理好友申请' : relationship === 'pending_outbound' ? '等待对方通过好友申请' : '当前无法发送消息'} disabled={!canSend} />
|
||||
<button type="button" aria-label="表情" disabled={!canSend} onClick={() => navigate('emoji-panel')}><Icon>sentiment_satisfied_alt</Icon></button>
|
||||
{draft.trim() ? <button type="submit" className="chat-send-button">发送</button> : <button type="button" aria-label="更多" disabled={!canSend} onClick={() => setAttachmentOpen(!attachmentOpen)}><Icon>{attachmentOpen ? 'keyboard' : 'add_circle_outline'}</Icon></button>}
|
||||
</form>;
|
||||
}
|
||||
|
||||
export default function DirectMessageView({view}) {
|
||||
const config = VIEW_CONFIG[view] || VIEW_CONFIG['chat-standard'];
|
||||
const [relationship, setRelationship] = React.useState(config.relationship);
|
||||
const [variant, setVariant] = React.useState(config.variant);
|
||||
const [attachmentOpen, setAttachmentOpen] = React.useState(config.variant === 'attachments');
|
||||
const [uploadProgress, setUploadProgress] = React.useState(config.variant === 'media' ? 76 : 100);
|
||||
const [sendStatus, setSendStatus] = React.useState('发送中');
|
||||
const [extraMessages, setExtraMessages] = React.useState([]);
|
||||
const [toast, setToast] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (variant !== 'status') return undefined;
|
||||
const stages = ['已发送', '已送达', '已读'];
|
||||
const timers = stages.map((status, index) => window.setTimeout(() => setSendStatus(status), 600 * (index + 1)));
|
||||
return () => timers.forEach(timer => window.clearTimeout(timer));
|
||||
}, [variant]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (variant !== 'media' || uploadProgress >= 100) return undefined;
|
||||
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(value + 4, 100)), 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [variant, uploadProgress]);
|
||||
|
||||
const friendAction = action => {
|
||||
if (action === 'declined') {
|
||||
setRelationship('none');
|
||||
setToast('已拒绝好友申请');
|
||||
} else {
|
||||
setRelationship(action === 'accepted' ? 'friend' : 'pending_outbound');
|
||||
if (action === 'accepted') setVariant('friend');
|
||||
setToast(action === 'accepted' ? '好友申请已通过' : '好友验证已发送');
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="chat-product-page">
|
||||
<header className="chat-product-header">
|
||||
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<div><h1>接语彤</h1><span><i />在线</span></div>
|
||||
<button type="button" onClick={() => navigate('friend-settings')} aria-label="会话设置"><Icon>more_vert</Icon></button>
|
||||
</header>
|
||||
<main className="chat-product-timeline">
|
||||
<ChatTimeline variant={variant} uploadProgress={uploadProgress} sendStatus={sendStatus}
|
||||
onRetry={() => setToast('正在重试发送')} onOpenCard={() => navigate('friend-profile')} onFriendAction={friendAction} />
|
||||
{extraMessages.map(item => <Bubble mine status={item.status} key={item.id}>{item.text}</Bubble>)}
|
||||
</main>
|
||||
{relationship === 'friend' ? null : <div className="chat-permission-banner"><Icon>info_outline</Icon>{relationship === 'pending_inbound' ? '通过好友申请后即可发送消息' : relationship === 'pending_outbound' ? '好友申请正在等待处理' : relationship === 'none' ? '对方已不是你的好友,发送消息前需重新验证' : '当前关系状态下无法发送消息'}</div>}
|
||||
<Composer relationship={relationship} attachmentOpen={attachmentOpen} setAttachmentOpen={setAttachmentOpen}
|
||||
onSend={text => {
|
||||
const id = Date.now();
|
||||
setExtraMessages(items => items.concat({id, text, status: '发送中'}));
|
||||
window.setTimeout(() => setExtraMessages(items => items.map(item => item.id === id ? {...item, status: '已发送'} : item)), 600);
|
||||
}} />
|
||||
{attachmentOpen && relationship === 'friend' ? <AttachmentPanel onChoose={item => {
|
||||
setToast(`已选择${item.label},上传接口将在联调阶段接入`);
|
||||
if (item.id === 'card') navigate('chat-contact-card');
|
||||
if (item.id === 'photo' || item.id === 'camera') navigate('media-uploading');
|
||||
if (item.id === 'call' || item.id === 'video') navigate('call-history');
|
||||
if (item.id === 'file') navigate('upload-file');
|
||||
if (item.id === 'favorite') navigate('favorites');
|
||||
}} /> : null}
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcodejs';
|
||||
import { pinyin } from 'pinyin-pro';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import {
|
||||
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
|
||||
updateMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
|
||||
acceptGroupJoinRequest, rejectGroupJoinRequest, getLatestGroupAnnouncement,
|
||||
updateCurrentGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||||
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
|
||||
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile
|
||||
} from '../lib/im-api.js';
|
||||
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||
|
||||
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
|
||||
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
|
||||
const navigate = (view, topic) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
|
||||
HashNavigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
|
||||
// viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it
|
||||
// can't go through this file's own `navigate` helper above without clobbering the wrong hash param.
|
||||
const navigateToProfile = userId => HashNavigation.navigateTo(
|
||||
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
|
||||
|
||||
export const GROUP_MANAGEMENT_VIEWS = [
|
||||
'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-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'
|
||||
];
|
||||
|
||||
// Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather
|
||||
// than shared, since that file's copy is unexported and this one is the only other place that needs it.
|
||||
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 groupBy(list, keyFn) {
|
||||
const groups = {};
|
||||
list.forEach(item => {
|
||||
const key = keyFn(item);
|
||||
(groups[key] || (groups[key] = [])).push(item);
|
||||
});
|
||||
return Object.keys(groups).sort().map(key => [key, groups[key]]);
|
||||
}
|
||||
|
||||
function shortDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// -- Demo-only mock data below this line: still used by the untouched GroupChat/ChatHeader/SystemEvent
|
||||
// -- trio (see comment above GroupChat). Those views are a standalone design reference showing message-
|
||||
// -- timeline states -- the real group chat timeline is messages-view.jsx, not this file (same treatment
|
||||
// -- Ch.3 gave direct-message-view.jsx). Nothing else in this file reads these constants any more.
|
||||
const MEMBERS = [
|
||||
{id: 'annie', name: '安妮', role: '管理员', avatar: 'img/bkg/p09.jpg'},
|
||||
{id: 'lizi', name: '栗子来了', role: '成员', avatar: 'img/bkg/p15.jpg'},
|
||||
{id: 'wang', name: '王珊珊', role: '成员', avatar: 'img/bkg/p14.jpg'},
|
||||
{id: 'sun', name: '孙光洪', role: '成员', avatar: 'img/bkg/p03.jpg'},
|
||||
{id: 'lin', name: '林间风', role: '成员', avatar: 'img/bkg/p18.jpg'}
|
||||
];
|
||||
const GROUPS_DEMO = {
|
||||
owned: [{id: 'product', name: '产品研发群', count: 18, avatar: 'img/bkg/p22.jpg'}]
|
||||
};
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
function Header({title, back = 'group-list', backTopic, right, onRight}) {
|
||||
return <header className="group-header">
|
||||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backTopic); }} 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, icon}) {
|
||||
const fileId = item && item.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="group-avatar">{url ? <img src={url} alt="" /> : <Icon>{icon || 'group'}</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;
|
||||
}
|
||||
// Centered overlay confirmation, docs/images/添加管理员-设置成功.png -- distinct from the bottom Toast
|
||||
// above (used for lighter "已同意进群申请"-style feedback that doesn't need a heavier interrupt).
|
||||
function CenterToast({message, onDone}) {
|
||||
React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 1400); return () => window.clearTimeout(timer); }, [message, onDone]);
|
||||
return message ? <div className="group-toast-center" role="status"><Icon>check_circle</Icon><span>{message}</span></div> : null;
|
||||
}
|
||||
function Search({value, onChange, placeholder = '搜索'}) {
|
||||
return <label className="group-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>;
|
||||
}
|
||||
function Toggle({checked, onChange, disabled}) {
|
||||
return <button type="button" role="switch" aria-checked={checked} disabled={disabled}
|
||||
className={'group-switch' + (checked ? ' checked' : '')} onClick={() => !disabled && onChange(!checked)}><i /></button>;
|
||||
}
|
||||
|
||||
function GroupList({onToast}) {
|
||||
// Only the unfiltered (query === '') result is worth caching -- caching a search result under the
|
||||
// same key would show stale search results instead of the real list on the next plain visit.
|
||||
const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null});
|
||||
const [loadErrors, setLoadErrors] = React.useState({owned: false, managed: false, joined: false});
|
||||
const [category, setCategory] = React.useState('owned');
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [name, setName] = React.useState('');
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const reload = React.useCallback(() => {
|
||||
['owned', 'managed', 'joined'].forEach(cat => {
|
||||
getGroups({category: cat, query}).then(data => {
|
||||
const items = data.items || [];
|
||||
setLists(v => {
|
||||
const next = {...v, [cat]: items};
|
||||
if (!query) writeCachedData('groups', next);
|
||||
return next;
|
||||
});
|
||||
}).catch(() => { onToast('加载失败'); setLoadErrors(v => ({...v, [cat]: true})); });
|
||||
});
|
||||
}, [query]);
|
||||
React.useEffect(() => { reload(); }, [reload]);
|
||||
const current = lists[category];
|
||||
const submit = () => {
|
||||
if (!name.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
createGroup({name: name.trim()}).then(() => {
|
||||
setCreating(false); setName(''); onToast('群聊创建成功'); reload();
|
||||
}).catch(err => onToast(err.message || '创建失败')).finally(() => setSubmitting(false));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="群聊" back="address-book" right="创建" onRight={() => setCreating(true)} />
|
||||
<Search value={query} onChange={setQuery} placeholder="搜索群聊" />
|
||||
<nav className="group-tabs">
|
||||
{[['owned', '我创建的'], ['managed', '我管理的'], ['joined', '我加入的']].map(([key, label]) =>
|
||||
<button type="button" key={key} className={category === key ? 'active' : ''} onClick={() => setCategory(key)}>
|
||||
{label}({(lists[key] || []).length})
|
||||
</button>)}
|
||||
</nav>
|
||||
<main className="group-list">
|
||||
{current === null ? <p className="group-inline-empty">{loadErrors[category] ? '群列表加载失败' : '加载中…'}</p> :
|
||||
current.length ? current.map(group =>
|
||||
<button type="button" key={group.topic} onClick={() => navigate('group-settings', group.topic)}>
|
||||
<Avatar item={group} />
|
||||
<span><strong>{group.name}</strong><small>{group.member_count} 位成员</small></span>
|
||||
<Icon>chevron_right</Icon>
|
||||
</button>) :
|
||||
<div className="group-empty"><Icon>groups</Icon><strong>这里还没有群聊</strong>
|
||||
<p>{category === 'owned' ? '创建一个群聊,邀请成员一起沟通' : '加入或被设置为管理员后会显示在这里'}</p>
|
||||
<button type="button" onClick={() => setCreating(true)}>创建群聊</button>
|
||||
</div>}
|
||||
</main>
|
||||
{creating ? <div className="group-modal-backdrop"><section className="group-modal">
|
||||
<h2>创建群聊</h2>
|
||||
<label>群名称<input autoFocus value={name} maxLength="30" onChange={event => setName(event.target.value)} placeholder="输入群名称" /></label>
|
||||
<p>创建后可继续邀请成员和设置群资料。</p>
|
||||
<footer>
|
||||
<button type="button" onClick={() => setCreating(false)}>取消</button>
|
||||
<button type="button" className="primary" disabled={!name.trim() || submitting} onClick={submit}>创建</button>
|
||||
</footer>
|
||||
</section></div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// GroupChat/ChatHeader/SystemEvent: standalone design-reference demo (docs/images/内容沟通对话*.png), same
|
||||
// treatment as Ch.3's direct-message-view.jsx -- the real group timeline is messages-view.jsx, which now
|
||||
// has its own real relationship/announcement/join-request/role-badge wiring. Left unchanged, not rewired.
|
||||
function ChatHeader() { return <header className="group-chat-header"><button type="button" onClick={() => navigate('group-list')}><Icon>arrow_back_ios_new</Icon></button><div><h1>产品研发群</h1><small>18人</small></div><button type="button" onClick={() => navigate('group-settings')}><Icon>more_vert</Icon></button></header>; }
|
||||
function SystemEvent({children}) { return <p className="group-system-event">{children}</p>; }
|
||||
function GroupChat({mode, onToast}) {
|
||||
const [joined, setJoined] = React.useState(false);
|
||||
return <div className="group-page"><ChatHeader />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS_DEMO.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>;
|
||||
}
|
||||
|
||||
function JoinRequests({topic, onToast}) {
|
||||
// A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests)
|
||||
// is a second entry point besides group-settings, and this whole feature remounts on every
|
||||
// navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working
|
||||
// through requests re-triggers this blank "加载中" flash on essentially every accept/reject.
|
||||
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
|
||||
const [profiles, setProfiles] = React.useState({});
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
// Tracked in a ref (not state) so `load` doesn't need `profiles` in its dependency array --
|
||||
// depending on `profiles` here would make `load` a new function every time a profile fetch
|
||||
// resolves, which would re-trigger the `useEffect(() => load(), [load])` below and re-fetch
|
||||
// the whole join-request list in a cascade, not just skip already-known profiles.
|
||||
const fetchedIds = React.useRef(new Set());
|
||||
const load = React.useCallback(() => {
|
||||
if (!topic) return;
|
||||
getGroupJoinRequests(topic, 'all').then(data => {
|
||||
const list = data.items || [];
|
||||
writeCachedData('group-join-requests:' + topic, list);
|
||||
setItems(list);
|
||||
const ids = new Set();
|
||||
list.forEach(r => { ids.add(r.user_id); if (r.inviter_id) ids.add(r.inviter_id); });
|
||||
ids.forEach(id => {
|
||||
if (fetchedIds.current.has(id)) return;
|
||||
fetchedIds.current.add(id);
|
||||
getUserProfile(id).then(p => setProfiles(v => ({...v, [id]: p}))).catch(() => { fetchedIds.current.delete(id); });
|
||||
});
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
React.useEffect(() => { setItems((topic && readCachedData('group-join-requests:' + topic)) || null); load(); }, [topic, load]);
|
||||
const handle = (req, accept) => {
|
||||
const call = accept ? acceptGroupJoinRequest(topic, req.request_id, req.version) : rejectGroupJoinRequest(topic, req.request_id, req.version);
|
||||
call.then(() => { onToast(accept ? '已同意进群申请' : '已拒绝进群申请'); load(); })
|
||||
.catch(err => onToast(err.message || '操作失败'));
|
||||
};
|
||||
const nameOf = id => (profiles[id] && profiles[id].display_name) || id;
|
||||
const describe = req => req.source === 'qr' ? `${nameOf(req.user_id)}通过二维码分享进群` :
|
||||
req.inviter_id ? `${nameOf(req.inviter_id)}邀请${nameOf(req.user_id)}进群` : `${nameOf(req.user_id)}申请加入群聊`;
|
||||
// Exact resolved/rejected label wording isn't confirmed against the live status enum yet -- falls back
|
||||
// to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong.
|
||||
const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'};
|
||||
return <div className="group-page">
|
||||
<Header title="邀请进群申请" back="group-settings" backTopic={topic} />
|
||||
<main className="group-request-list">
|
||||
{items === null ? <p className="group-inline-empty">{loadError ? '申请列表加载失败' : '加载中…'}</p> :
|
||||
items.length ? items.map(item =>
|
||||
<article key={item.request_id}>
|
||||
<Avatar item={profiles[item.user_id] || {}} />
|
||||
<div><strong>{describe(item)}</strong><small>{shortDate(item.created_at)}</small></div>
|
||||
{item.status === 'pending' ?
|
||||
<span>
|
||||
<button type="button" onClick={() => handle(item, false)}>拒绝</button>
|
||||
<button type="button" className="primary" onClick={() => handle(item, true)}>确定</button>
|
||||
</span> :
|
||||
<em>{statusLabel[item.status] || item.status}</em>}
|
||||
</article>) :
|
||||
<div className="group-inline-empty">暂无进群申请</div>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Announcement({topic, myRole, fromChat, onToast}) {
|
||||
// Shares the 'group-announcement:'+topic cache key with GroupSettings' preview snippet below --
|
||||
// whichever screen loads first warms the cache for the other, and both are re-entered often (the
|
||||
// in-chat banner is a persistent second entry point on top of group-settings).
|
||||
const cachedAnnouncement = topic && readCachedData('group-announcement:' + topic);
|
||||
const [data, setData] = React.useState(() => cachedAnnouncement || null);
|
||||
const [draft, setDraft] = React.useState(() => (cachedAnnouncement && cachedAnnouncement.content) || '');
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const canEdit = myRole === 'owner' || myRole === 'admin';
|
||||
// Coming from the announcement banner atop the chat itself (messages-view.jsx's
|
||||
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
|
||||
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
|
||||
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
|
||||
const backView = fromChat ? '' : 'group-settings';
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
const cached = readCachedData('group-announcement:' + topic);
|
||||
if (cached) { setData(cached); setDraft(cached.content || ''); }
|
||||
getLatestGroupAnnouncement(topic).then(d => {
|
||||
writeCachedData('group-announcement:' + topic, d);
|
||||
setData(d); setDraft(d.content || '');
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
const publish = () => {
|
||||
if (!draft.trim() || saving) return;
|
||||
setSaving(true);
|
||||
updateCurrentGroupAnnouncement(topic, draft.trim(), data ? data.version : 0).then(d => {
|
||||
writeCachedData('group-announcement:' + topic, d);
|
||||
setData(d); setEditing(false); onToast('群公告已发布');
|
||||
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
|
||||
};
|
||||
if (!data) return <div className="group-page"><Header title="群公告" back={backView} backTopic={topic} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
|
||||
return <div className="group-page">
|
||||
<Header title="群公告" back={backView} backTopic={topic}
|
||||
right={canEdit ? '发布' : null} onRight={canEdit ? publish : undefined} />
|
||||
<main className="group-announcement-detail">
|
||||
{canEdit ? <textarea autoFocus={editing} value={draft} maxLength={2000}
|
||||
onChange={e => setDraft(e.target.value)} onFocus={() => setEditing(true)}
|
||||
placeholder="请输入群公告内容" /> :
|
||||
<>
|
||||
<Icon>campaign</Icon>
|
||||
<p>{data.content || '暂无群公告'}</p>
|
||||
</>}
|
||||
{!canEdit ? <small className="group-inline-hint">仅群主及群管理员可编辑</small> : null}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GroupSettings({topic, myUserId, tinode, onToast}) {
|
||||
// This is the landing point for "back" from GroupProfile/GroupFiles/Governance/MyNicknameEditor/
|
||||
// Announcement, and (per GroupManagementView's `key={view}` note) the whole subtree remounts on
|
||||
// every one of those round trips -- essentially every group-settings visit after the first refetches
|
||||
// from scratch, making this the single highest-traffic page in this file to seed from cache.
|
||||
const [group, setGroup] = React.useState(() => (topic && readCachedData('group-settings:' + topic)) || null);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
// The member strip and AdminList/`myRole` all call the identical unfiltered getGroupMembers(topic) --
|
||||
// sharing one 'group-members:'+topic cache entry (storing the raw list, sliced to 9 only for local
|
||||
// display here) lets any one of the three warm it for the others.
|
||||
const [members, setMembers] = React.useState(() => {
|
||||
const cached = topic && readCachedData('group-members:' + topic);
|
||||
return cached ? cached.slice(0, 9) : [];
|
||||
});
|
||||
const [announcement, setAnnouncement] = React.useState(() => {
|
||||
const cached = topic && readCachedData('group-announcement:' + topic);
|
||||
return cached && cached.content ? cached : null;
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
setGroup(readCachedData('group-settings:' + topic) || null);
|
||||
setMembers((readCachedData('group-members:' + topic) || []).slice(0, 9));
|
||||
const cachedAnn = readCachedData('group-announcement:' + topic);
|
||||
setAnnouncement(cachedAnn && cachedAnn.content ? cachedAnn : null);
|
||||
getGroupSettings(topic).then(g => { writeCachedData('group-settings:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
getGroupMembers(topic).then(data => {
|
||||
const items = data.items || [];
|
||||
writeCachedData('group-members:' + topic, items);
|
||||
setMembers(items.slice(0, 9));
|
||||
}).catch(() => {});
|
||||
getLatestGroupAnnouncement(topic).then(d => {
|
||||
writeCachedData('group-announcement:' + topic, d);
|
||||
setAnnouncement(d && d.content ? d : null);
|
||||
}).catch(() => {});
|
||||
}, [topic]);
|
||||
const setPref = patch => {
|
||||
updateGroupMemberSettings(topic, {pinned: group.pinned, mute_notifications: group.mute_notifications, ...patch})
|
||||
.then(() => setGroup(g => {
|
||||
const next = {...g, ...patch};
|
||||
writeCachedData('group-settings:' + topic, next);
|
||||
return next;
|
||||
})).catch(err => onToast(err.message || '设置失败'));
|
||||
};
|
||||
const leave = () => {
|
||||
if (group && group.my_role === 'owner') { onToast('群主需先转让群主或解散群聊'); return; }
|
||||
leaveGroup(topic).then(() => { onToast('已退出群聊'); navigate('group-list'); }).catch(err => onToast(err.message || '操作失败'));
|
||||
};
|
||||
if (!group) return <div className="group-page"><Header title="群聊设置" back="group-list" /><main className="group-settings"><p className="group-inline-empty">{loadError ? '群组信息加载失败' : '加载中…'}</p></main></div>;
|
||||
const canManage = group.permissions && group.permissions.can_manage_settings;
|
||||
const links = [
|
||||
['group-profile', '群资料', group.name],
|
||||
['group-announcement-detail', '群公告', announcement ? announcement.content : '暂无群公告'],
|
||||
['group-files', '群文件', ''],
|
||||
['group-my-nickname', '我在本群的昵称', '']
|
||||
];
|
||||
if (canManage) links.splice(3, 0, ['group-governance', '群组管理', '']);
|
||||
return <div className="group-page">
|
||||
<Header title="群聊设置" back="group-list" right={canManage ? '管理' : null}
|
||||
onRight={canManage ? () => navigate('group-governance', topic) : undefined} />
|
||||
<main className="group-settings">
|
||||
<button type="button" className="group-settings-header" onClick={() => navigate('group-profile', topic)}>
|
||||
<Avatar item={group} />
|
||||
<span><strong>{group.name}</strong><small>{group.description || '群主很懒,还没有群简介'}</small></span>
|
||||
<Icon>chevron_right</Icon>
|
||||
</button>
|
||||
<section className="group-member-strip">
|
||||
<div>{members.map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(item.user_id)}>
|
||||
<Avatar item={item} /><small>{item.group_nickname || item.display_name}</small>
|
||||
</button>)}</div>
|
||||
<button type="button" aria-label="邀请成员" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}><Icon>person_add_alt</Icon></button>
|
||||
</section>
|
||||
<p className="group-inline-hint">共 {group.member_count} 位成员</p>
|
||||
<section>{links.map(item => <button type="button" key={item[0]} onClick={() => navigate(item[0], topic)}>
|
||||
<span>{item[1]}</span><small>{item[2]}</small><Icon>chevron_right</Icon>
|
||||
</button>)}</section>
|
||||
<section>
|
||||
<div><span>置顶聊天</span><Toggle checked={!!group.pinned} onChange={v => setPref({pinned: v})} /></div>
|
||||
<div><span>消息免打扰</span><Toggle checked={!!group.mute_notifications} onChange={v => setPref({mute_notifications: v})} /></div>
|
||||
<button type="button" onClick={() => navigate('history-search', topic)}><span>查看聊天记录</span><Icon>chevron_right</Icon></button>
|
||||
<button type="button" onClick={() => {
|
||||
const t = tinode ? tinode.getTopic(topic) : null;
|
||||
(t ? t.delMessagesAll(false) : Promise.resolve()).then(() => onToast('聊天记录已清除')).catch(err => onToast(err.message || '操作失败'));
|
||||
}}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
|
||||
<button type="button" onClick={() => onToast('举报入口暂未开放')}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
|
||||
</section>
|
||||
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GroupProfile({topic, onToast}) {
|
||||
// Shares 'group:'+topic with the nickname/description editors' own getGroup(topic) calls -- opening
|
||||
// this once warms the cache for either of those too.
|
||||
const [group, setGroup] = React.useState(() => (topic && readCachedData('group:' + topic)) || null);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
setGroup(readCachedData('group:' + topic) || null);
|
||||
getGroup(topic).then(g => { writeCachedData('group:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
if (!group) return <div className="group-page"><Header title="群资料" back="group-settings" backTopic={topic} /><main className="group-profile"><p className="group-inline-empty">{loadError ? '群组信息加载失败' : '加载中…'}</p></main></div>;
|
||||
const canEdit = group.permissions && group.permissions.can_edit_profile;
|
||||
return <div className="group-page">
|
||||
<Header title="群资料" back="group-settings" backTopic={topic} />
|
||||
<main className="group-profile">
|
||||
<button type="button" className="group-profile-avatar" disabled={!canEdit} onClick={() => canEdit && navigate('group-avatar', topic)}>
|
||||
<Avatar item={group} /><span>群头像</span><Icon>chevron_right</Icon>
|
||||
</button>
|
||||
{canEdit ? <button type="button" onClick={() => navigate('group-nickname', topic)}>
|
||||
<span>群昵称</span><small>{group.name}</small><Icon>chevron_right</Icon>
|
||||
</button> :
|
||||
<div><span>群昵称</span><small>{group.name}</small></div>}
|
||||
{canEdit ? <button type="button" onClick={() => navigate('group-description', topic)}>
|
||||
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
|
||||
</button> :
|
||||
<div><span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small></div>}
|
||||
<button type="button" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
|
||||
<p>{group.member_count} 位成员</p>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AvatarSheet({topic, tinode, onToast}) {
|
||||
const inputRef = React.useRef(null);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const pick = () => inputRef.current && inputRef.current.click();
|
||||
const onFile = e => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file || !tinode) return;
|
||||
setUploading(true);
|
||||
// Fetch the current version right before the write, same optimistic-concurrency protection the
|
||||
// name/description editors already have (GroupProfile below re-fetches independently and its
|
||||
// possibly-stale copy isn't reused here) -- avatar updates were the one group-profile field with
|
||||
// none at all.
|
||||
uploadLargeFile(tinode, file)
|
||||
.then(({fileId}) => getGroup(topic).then(group => updateGroup(topic, {avatar_file_id: fileId, version: group.version})))
|
||||
.then(() => { onToast('群头像已更新'); navigate('group-profile', topic); })
|
||||
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<GroupProfile topic={topic} onToast={onToast} />
|
||||
<input ref={inputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={onFile} />
|
||||
<div className="group-sheet-backdrop"><section className="group-source-sheet">
|
||||
<h2>设置群头像</h2>
|
||||
<button type="button" disabled={uploading} onClick={pick}><Icon>photo_library</Icon>{uploading ? '正在上传…' : '从相册选择'}</button>
|
||||
<button type="button" onClick={() => navigate('group-profile', topic)}>取消</button>
|
||||
</section></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GroupQr({topic, onToast}) {
|
||||
const ref = React.useRef(null);
|
||||
const [data, setData] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
createGroupQRCode(topic).then(setData).catch(() => onToast('二维码生成失败'));
|
||||
}, [topic]);
|
||||
React.useEffect(() => {
|
||||
if (!ref.current || !data) return;
|
||||
ref.current.innerHTML = '';
|
||||
new QRCode(ref.current, {text: data.qr_payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
|
||||
}, [data]);
|
||||
const group = data && data.group_snapshot;
|
||||
const share = () => {
|
||||
if (navigator.share && data) {
|
||||
navigator.share({title: group && group.name, text: '扫码加入群聊', url: data.qr_payload}).catch(() => {});
|
||||
} else if (data) {
|
||||
navigator.clipboard.writeText(data.qr_payload).then(() => onToast('链接已复制')).catch(() => onToast('分享失败'));
|
||||
}
|
||||
};
|
||||
const save = () => {
|
||||
const canvas = ref.current && ref.current.querySelector('canvas');
|
||||
if (!canvas) { onToast('保存失败'); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = (group ? group.name : 'group') + '-qrcode.png';
|
||||
a.click();
|
||||
onToast('群二维码已保存');
|
||||
};
|
||||
return <div className="group-page group-qr-page">
|
||||
<Header title="群二维码" back="group-profile" backTopic={topic} />
|
||||
<main>
|
||||
<section className="group-qr-card">
|
||||
<div><Avatar item={group || {}} /><span><strong>{group ? group.name : ''}</strong><small>{group ? group.member_count : 0} 位成员</small></span></div>
|
||||
<div className="group-qrcode" ref={ref} />
|
||||
<p>使用APP扫码二维码,加入群聊</p>
|
||||
</section>
|
||||
<div className="group-qr-actions">
|
||||
<button type="button" onClick={share}><Icon>share</Icon>分享好友</button>
|
||||
<button type="button" onClick={save}><Icon>download</Icon>保存至相册</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// The five backend-persisted governance fields (server/im_http.go imGroupGovernancePatchRequest) --
|
||||
// docs/images/群组管理.png showed a 4th toggle "群成员修改昵称" that doesn't correspond to any real
|
||||
// governance field (its own description text also doesn't match member_upload_files' actual meaning,
|
||||
// which a *different*, buggier draft screenshot happened to label correctly). Shipping a toggle with
|
||||
// nothing to persist would be worse than a label mismatch, so this uses the mock's original 5-key
|
||||
// structure -- which lines up exactly with the 5 real backend fields -- with corrected descriptions.
|
||||
const GOVERNANCE_FIELDS = [
|
||||
['join_approval', '进群验证', '开启后,他人申请加入群聊需群主或管理员同意'],
|
||||
['mute_all', '全体禁言', '开启后,仅群主和管理员可以发言'],
|
||||
['member_upload_files', '群成员文件上传', '关闭后,普通成员将无法上传群文件'],
|
||||
['member_invite', '群成员邀请', '关闭后,仅群主和管理员可以邀请新成员进群'],
|
||||
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
|
||||
];
|
||||
|
||||
function Governance({topic, tinode, onToast}) {
|
||||
// Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's
|
||||
// `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks
|
||||
// and returns, so this is one of the highest-traffic pages in the file.
|
||||
const [gov, setGov] = React.useState(() => (topic && readCachedData('group-governance:' + topic)) || null);
|
||||
const [dissolving, setDissolving] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
setGov(readCachedData('group-governance:' + topic) || null);
|
||||
getGroupGovernance(topic).then(d => { writeCachedData('group-governance:' + topic, d); setGov(d); }).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
const setField = (key, value) => {
|
||||
updateGroupGovernance(topic, {[key]: value}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||||
.catch(err => onToast(err.message || '设置失败'));
|
||||
};
|
||||
const dissolve = () => {
|
||||
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
|
||||
const t = tinode ? tinode.getTopic(topic) : null;
|
||||
if (!t) return;
|
||||
setDissolving(true);
|
||||
t.delTopic(true).then(() => { onToast('群聊已解散'); navigate('group-list'); })
|
||||
.catch(err => onToast(err.message || '解散失败')).finally(() => setDissolving(false));
|
||||
};
|
||||
if (!gov) return <div className="group-page"><Header title="群组管理" back="group-settings" backTopic={topic} /><main className="group-governance"><p className="group-inline-empty">{loadError ? '群组管理加载失败' : '加载中…'}</p></main></div>;
|
||||
return <div className="group-page">
|
||||
<Header title="群组管理" back="group-settings" backTopic={topic} />
|
||||
<main className="group-governance">
|
||||
<button type="button" onClick={() => navigate('group-avatar', topic)}><span>设置群头像</span><Icon>chevron_right</Icon></button>
|
||||
<section>
|
||||
<button type="button" onClick={() => navigate('group-join-requests', topic)}><span>邀请进群申请</span><Icon>chevron_right</Icon></button>
|
||||
<button type="button" onClick={() => navigate('group-muted', topic)}><span>禁言的群成员</span><Icon>chevron_right</Icon></button>
|
||||
<button type="button" onClick={() => navigate('group-admins', topic)}><span>设置管理员</span><Icon>chevron_right</Icon></button>
|
||||
<button type="button" onClick={() => navigate('group-owner-select', topic)}><span>群主管理权转让</span><Icon>chevron_right</Icon></button>
|
||||
</section>
|
||||
<section>{GOVERNANCE_FIELDS.map(([key, label, desc]) => <div key={key}>
|
||||
<span><strong>{label}</strong><small>{desc}</small></span>
|
||||
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
|
||||
</div>)}</section>
|
||||
<button type="button" className="group-danger" disabled={dissolving} onClick={dissolve}>解散群组</button>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function deriveOwnerAndAdmins(items) {
|
||||
return {owner: items.find(m => m.role === 'owner') || null, admins: items.filter(m => m.role === 'admin')};
|
||||
}
|
||||
|
||||
function AdminList({topic, onToast}) {
|
||||
// Shares the same 'group-members:'+topic cache entry as GroupSettings' member strip and
|
||||
// GroupManagementView's `myRole` lookup -- all three call the identical unfiltered getGroupMembers.
|
||||
const cachedMembers = topic && readCachedData('group-members:' + topic);
|
||||
const seed = cachedMembers ? deriveOwnerAndAdmins(cachedMembers) : {owner: null, admins: null};
|
||||
const [owner, setOwner] = React.useState(() => seed.owner);
|
||||
const [admins, setAdmins] = React.useState(() => seed.admins);
|
||||
const [toast, setToast] = React.useState('');
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const load = React.useCallback(() => {
|
||||
if (!topic) return;
|
||||
const cached = readCachedData('group-members:' + topic);
|
||||
if (cached) { const d = deriveOwnerAndAdmins(cached); setOwner(d.owner); setAdmins(d.admins); }
|
||||
// Fetch the unfiltered member list rather than {role:'admin'} + a separate per-user profile lookup
|
||||
// for the owner -- GET /users/{id}/profile 400s ("malformed request", field "user_id") when {id} is
|
||||
// the caller's own uid, which broke this whenever the viewer is themselves the group's owner.
|
||||
getGroupMembers(topic).then(data => {
|
||||
const items = data.items || [];
|
||||
writeCachedData('group-members:' + topic, items);
|
||||
const d = deriveOwnerAndAdmins(items);
|
||||
setOwner(d.owner);
|
||||
setAdmins(d.admins);
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
const remove = userId => {
|
||||
deleteGroupAdmin(topic, userId).then(() => { setToast('管理员已移除'); load(); })
|
||||
.catch(err => onToast(err.message || '操作失败'));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
|
||||
<main className="group-admin-page">
|
||||
<aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 10 名管理员。</p></aside>
|
||||
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.display_name}</strong><small>群主</small></span></article></> : null}
|
||||
<h2>管理员 {admins ? admins.length : 0}/10</h2>
|
||||
{admins === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
|
||||
admins.length ? admins.map(item => <article key={item.user_id}>
|
||||
<Avatar item={item} /><span><strong>{item.display_name}</strong><small>管理员</small></span>
|
||||
<button type="button" onClick={() => remove(item.user_id)}>移除</button>
|
||||
</article>) : <div className="group-inline-empty">暂无管理员</div>}
|
||||
</main>
|
||||
<CenterToast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
// Shared alphabetically-grouped multi-select picker for admin-add / mute-add (same visual component,
|
||||
// three purposes, per docs/images/添加管理员.png vs .../添加管理员-设置成功(1).png which turned out to
|
||||
// actually be the mute-add variant of the identical layout).
|
||||
function MemberPicker({type, topic, onToast}) {
|
||||
const mute = type === 'mute-add';
|
||||
const back = mute ? 'group-muted' : 'group-admins';
|
||||
const [members, setMembers] = React.useState(null);
|
||||
const [selected, setSelected] = React.useState([]);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [toast, setToast] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
getGroupMembers(topic, mute ? {muteEligible: true} : {eligibleRole: 'admin'})
|
||||
.then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
|
||||
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic, mute]);
|
||||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
|
||||
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
|
||||
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
|
||||
const done = () => {
|
||||
if (!selected.length || busy) return;
|
||||
setBusy(true);
|
||||
const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected);
|
||||
call.then(result => {
|
||||
const failed = result && result.succeeded ? result.succeeded.filter(s => !s.ok) : [];
|
||||
setToast(failed.length ? `成功设置 ${selected.length - failed.length} 人,${failed.length} 人设置失败` : '设置成功');
|
||||
window.setTimeout(() => navigate(back, topic), 900);
|
||||
}).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title={mute ? '添加禁言成员' : '添加管理员'} back={back} backTopic={topic}
|
||||
right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} />
|
||||
<Search value={query} onChange={setQuery} placeholder="搜索群成员" />
|
||||
<main className="group-member-picker">
|
||||
{members === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : groups.map(([letter, list]) => <React.Fragment key={letter}>
|
||||
<div className="group-section-title">{letter}</div>
|
||||
{list.map(item => <button type="button" key={item.user_id} onClick={() => toggle(item.user_id)}>
|
||||
<Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong></span>
|
||||
<i className={'group-check' + (selected.includes(item.user_id) ? ' checked' : '')}>
|
||||
{selected.includes(item.user_id) ? <Icon>check</Icon> : null}
|
||||
</i>
|
||||
</button>)}
|
||||
</React.Fragment>)}
|
||||
</main>
|
||||
<CenterToast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Muted({topic, onToast}) {
|
||||
const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);
|
||||
// groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --
|
||||
// confirmed against the real backend struct), so without this it rendered raw ids like "usr8x2kf9"
|
||||
// instead of names. The full member roster already carries group_nickname/display_name for
|
||||
// everyone still in the group, and fetching it here is one extra request for the whole list
|
||||
// (not per-row), unlike JoinRequests' per-item getUserProfile lookup.
|
||||
const [names, setNames] = React.useState({});
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const load = React.useCallback(() => {
|
||||
if (!topic) return;
|
||||
getGroupMutes(topic).then(data => {
|
||||
const list = data.items || [];
|
||||
writeCachedData('group-muted:' + topic, list);
|
||||
setItems(list);
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
getGroupMembers(topic).then(data => {
|
||||
const map = {};
|
||||
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
|
||||
setNames(map);
|
||||
}).catch(() => {});
|
||||
}, [topic]);
|
||||
React.useEffect(() => { setItems((topic && readCachedData('group-muted:' + topic)) || null); load(); }, [topic, load]);
|
||||
const unmute = userId => {
|
||||
unmuteGroupMember(topic, userId).then(() => { onToast('已解除禁言'); load(); })
|
||||
.catch(err => onToast(err.message || '操作失败'));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="禁言的群成员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-mute-add', topic)} />
|
||||
<main className="group-muted-list">
|
||||
{items === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
|
||||
items.length ? items.map(item => <article key={item.user_id}>
|
||||
<Avatar item={{}} /><span><strong>{names[item.user_id] || item.user_id}</strong><small>{item.expires_at ? `禁言至 ${shortDate(item.expires_at)}` : '永久禁言'}</small></span>
|
||||
<button type="button" onClick={() => unmute(item.user_id)}>解除</button>
|
||||
</article>) : <div className="group-inline-empty">以上成员不允许发言</div>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function OwnerSelect({topic, onToast}) {
|
||||
const [members, setMembers] = React.useState(null);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [target, setTarget] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
getGroupMembers(topic, {eligibleRole: 'owner'}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
|
||||
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
|
||||
const admins = visible.filter(m => m.role === 'admin');
|
||||
const others = visible.filter(m => m.role !== 'admin');
|
||||
const confirm = () => {
|
||||
if (busy || !target) return;
|
||||
setBusy(true);
|
||||
transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
|
||||
.catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));
|
||||
};
|
||||
const row = item => <article key={item.user_id} role="button" tabIndex={0} onClick={() => setTarget(item)}>
|
||||
<Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong></span>
|
||||
</article>;
|
||||
return <div className="group-page">
|
||||
<Header title="选择新群主" back="group-governance" backTopic={topic} />
|
||||
<Search value={query} onChange={setQuery} placeholder="搜索" />
|
||||
<main className="group-request-list">
|
||||
{members === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : <>
|
||||
{admins.length ? <><div className="group-section-title">管理员</div>{admins.map(row)}</> : null}
|
||||
{others.length ? <><div className="group-section-title">群成员</div>{others.map(row)}</> : null}
|
||||
</>}
|
||||
</main>
|
||||
{target ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
|
||||
<h2>选择新群主</h2>
|
||||
<p>{target.group_nickname || target.display_name}成为新群主,确定后你将立即失去群主身份</p>
|
||||
<button type="button" className="danger" disabled={busy} onClick={confirm}>确定</button>
|
||||
<button type="button" onClick={() => setTarget(null)}>取消</button>
|
||||
</section></div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
const FILE_TYPE_META = {
|
||||
image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'],
|
||||
document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']
|
||||
};
|
||||
|
||||
function GroupFiles({topic, tinode, onToast}) {
|
||||
const [query, setQuery] = React.useState('');
|
||||
// Only the unfiltered (query === '') result is cached -- same rule GroupList already follows, so a
|
||||
// plain revisit never shows a stale search result under a fresh empty query.
|
||||
const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [uploading, setUploading] = React.useState(null);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};
|
||||
const load = React.useCallback(() => {
|
||||
if (!topic) return;
|
||||
getGroupFiles(topic, {query}).then(data => {
|
||||
const items = data.items || [];
|
||||
if (!query) writeCachedData('group-files:' + topic, items);
|
||||
setFiles(items);
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic, query]);
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);
|
||||
const pick = kind => { setMenuOpen(false); inputRefs[kind].current && inputRefs[kind].current.click(); };
|
||||
const onFile = e => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file || !tinode) return;
|
||||
setUploading({name: file.name, progress: 0});
|
||||
uploadLargeFile(tinode, file, frac => setUploading(u => u ? {...u, progress: Math.round(frac * 100)} : u))
|
||||
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size}))
|
||||
.then(() => { setUploading(null); load(); })
|
||||
.catch(err => { setUploading(null); onToast(err.message || '上传失败'); });
|
||||
};
|
||||
const remove = fileId => {
|
||||
deleteGroupFile(topic, fileId).then(() => load()).catch(err => onToast(err.message || '删除失败'));
|
||||
};
|
||||
const gb = n => (n / (1024 ** 3)).toFixed(2);
|
||||
return <div className="group-page">
|
||||
<Header title="群文件" back="group-settings" backTopic={topic}
|
||||
right={<Icon>add</Icon>} onRight={() => setMenuOpen(v => !v)} />
|
||||
<input ref={inputRefs.file} type="file" style={{display: 'none'}} onChange={onFile} />
|
||||
<input ref={inputRefs.image} type="file" accept="image/*" style={{display: 'none'}} onChange={onFile} />
|
||||
<input ref={inputRefs.video} type="file" accept="video/*" style={{display: 'none'}} onChange={onFile} />
|
||||
{menuOpen ? <div className="group-upload-menu">
|
||||
<button type="button" onClick={() => pick('file')}><Icon>upload_file</Icon>上传文件</button>
|
||||
<button type="button" onClick={() => pick('image')}><Icon>image</Icon>上传照片</button>
|
||||
<button type="button" onClick={() => pick('video')}><Icon>play_circle_outline</Icon>上传视频</button>
|
||||
</div> : null}
|
||||
<Search value={query} onChange={setQuery} placeholder="搜索文件名或者上传者" />
|
||||
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB/10GB)</p>
|
||||
<main className="group-file-list">
|
||||
{uploading ? <article className="uploading">
|
||||
<span className="group-file-type" style={{backgroundColor: '#8a8f98'}}><Icon>upload_file</Icon></span>
|
||||
<div><strong>{uploading.name}</strong><small>上传中 {uploading.progress}%</small><progress value={uploading.progress} max="100" /></div>
|
||||
</article> : null}
|
||||
{files === null ? <p className="group-inline-empty">{loadError ? '文件列表加载失败' : '加载中…'}</p> :
|
||||
files.map(file => {
|
||||
const [icon, color] = FILE_TYPE_META[file.file_type] || FILE_TYPE_META.file;
|
||||
return <article key={file.group_file_id}>
|
||||
<span className="group-file-type" style={{backgroundColor: color}}><Icon>{icon}</Icon></span>
|
||||
<div><strong>{file.file_name || '未命名文件'}</strong><small>{bytesToGB(file.file_size)} · {file.uploader_id}</small></div>
|
||||
<button type="button" onClick={() => remove(file.group_file_id)}><Icon>delete_outline</Icon></button>
|
||||
</article>;
|
||||
})}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function bytesToGB(bytes) {
|
||||
if (!bytes) return '0 KB';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function GroupNameEditor({topic, onToast}) {
|
||||
const [group, setGroup] = React.useState(null);
|
||||
const [value, setValue] = React.useState('');
|
||||
React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.name); }).catch(() => onToast('加载失败')); }, [topic]);
|
||||
const save = () => {
|
||||
if (!value.trim()) return;
|
||||
updateGroup(topic, {name: value.trim(), version: group ? group.version : 0}).then(() => {
|
||||
onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);
|
||||
}).catch(err => onToast(err.message || '保存失败'));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="群昵称" back="group-profile" backTopic={topic} right="完成" onRight={save} />
|
||||
<main className="group-edit-text"><input autoFocus value={value} maxLength={11} onChange={e => setValue(e.target.value)} /><small>{value.length}/11</small></main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GroupDescriptionEditor({topic, onToast}) {
|
||||
const [group, setGroup] = React.useState(null);
|
||||
const [value, setValue] = React.useState('');
|
||||
React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.description || ''); }).catch(() => onToast('加载失败')); }, [topic]);
|
||||
const publish = () => {
|
||||
updateGroup(topic, {description: value.trim(), version: group ? group.version : 0}).then(() => {
|
||||
onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);
|
||||
}).catch(err => onToast(err.message || '保存失败'));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="群简介" back="group-profile" backTopic={topic} right="发布" onRight={publish} />
|
||||
<main className="group-edit-text">
|
||||
<textarea autoFocus value={value} maxLength={225} placeholder="请输入群简介内容" onChange={e => setValue(e.target.value)} />
|
||||
<small>{value.length}/225</small>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// "我在本群的昵称" has no dedicated reference screenshot (see plan notes) -- placed in GroupSettings,
|
||||
// governed by the real member_invite... no, by the group_nickname field on groupMemberPayload, gated by
|
||||
// the "群成员修改昵称" concept the images described (server-side enforcement, not re-checked client-side).
|
||||
function MyNicknameEditor({topic, myUserId, onToast}) {
|
||||
const [value, setValue] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
if (!topic || !myUserId) return;
|
||||
getGroupMembers(topic, {query: ''}).then(data => {
|
||||
const me = (data.items || []).find(m => m.user_id === myUserId);
|
||||
if (me) setValue(me.group_nickname || '');
|
||||
}).catch(() => {});
|
||||
}, [topic, myUserId]);
|
||||
const save = () => {
|
||||
updateMyGroupProfile(topic, value.trim(), 0).then(() => {
|
||||
onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);
|
||||
}).catch(err => onToast(err.message || '保存失败'));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="我在本群的昵称" back="group-settings" backTopic={topic} right="完成" onRight={save} />
|
||||
<main className="group-edit-text"><input autoFocus value={value} maxLength={32} onChange={e => setValue(e.target.value)} /><small>群昵称仅在当前群内显示。</small></main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function GroupManagementView({view, hashParams, myUserId, tinode, onTopicSelected}) {
|
||||
const [toast, setToast] = React.useState('');
|
||||
const topic = hashParams && hashParams.topic;
|
||||
// This container remounts on every navigation step inside the feature (see `key={view}` in
|
||||
// sidepanel-view.jsx), and Announcement's edit/publish UI is gated on this role -- uncached, that
|
||||
// gate visibly flickers off and back on every time the announcement page (or any sibling page) is
|
||||
// opened. Also warms the same shared 'group-members:'+topic entry GroupSettings/AdminList use.
|
||||
const roleCacheKey = topic && myUserId ? `group-my-role:${topic}:${myUserId}` : null;
|
||||
const [myRole, setMyRole] = React.useState(() => (roleCacheKey && readCachedData(roleCacheKey)) || null);
|
||||
React.useEffect(() => {
|
||||
if (!topic || !myUserId) { setMyRole(null); return; }
|
||||
const key = `group-my-role:${topic}:${myUserId}`;
|
||||
setMyRole(readCachedData(key) || null);
|
||||
getGroupMembers(topic).then(data => {
|
||||
const items = data.items || [];
|
||||
writeCachedData('group-members:' + topic, items);
|
||||
const me = items.find(m => m.user_id === myUserId);
|
||||
const role = me ? me.role : null;
|
||||
writeCachedData(key, role);
|
||||
setMyRole(role);
|
||||
}).catch(() => {});
|
||||
}, [topic, myUserId]);
|
||||
let content;
|
||||
if (view === 'group-list' || view === 'group-empty') content = <GroupList onToast={setToast} />;
|
||||
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
|
||||
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
|
||||
else if (view === 'group-settings') content = <GroupSettings topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
|
||||
else if (view === 'group-profile') content = <GroupProfile topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-avatar') content = <AvatarSheet topic={topic} tinode={tinode} onToast={setToast} />;
|
||||
else if (view === 'group-nickname') content = <GroupNameEditor topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-description') content = <GroupDescriptionEditor topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
|
||||
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-governance') content = <Governance topic={topic} tinode={tinode} onToast={setToast} />;
|
||||
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-admin-add') content = <MemberPicker type="admin-add" topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-mute-add') content = <MemberPicker type="mute-add" topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-muted') content = <Muted topic={topic} onToast={setToast} />;
|
||||
else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = <OwnerSelect topic={topic} onToast={setToast} />;
|
||||
else content = <GroupFiles topic={topic} tinode={tinode} onToast={setToast} />;
|
||||
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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';
|
||||
|
||||
// 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));
|
||||
|
||||
// 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: '撤回', gated: true},
|
||||
{id: 'delete', icon: 'delete_outline', label: '删除', gated: true},
|
||||
{id: 'select', icon: 'select_all', 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}) {
|
||||
return <i className="material-icons" aria-hidden="true">{children}</i>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="messages-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
function Avatar({item, small = false}) {
|
||||
// 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 [];
|
||||
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) summary = draftyPreview(latest.content);
|
||||
if (latest && latest.from === myUserId) summary = '我:' + summary;
|
||||
return {
|
||||
id: item.topic || item.user || String(index),
|
||||
title: topicDisplayName(item.topic, item.public, '未命名会话'),
|
||||
summary,
|
||||
time: formatConversationTime(item.touched),
|
||||
unread: Math.max(Number(item.unread) || 0, 0),
|
||||
online: !!item.online,
|
||||
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
|
||||
pinned: !!item.pinned
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function BottomNavigation() {
|
||||
return <nav className="messages-bottom-nav" aria-label="主导航">
|
||||
<button type="button" onClick={() => navigate('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
|
||||
<button type="button" className="active"><Icon>person</Icon><span>消息</span></button>
|
||||
</nav>;
|
||||
}
|
||||
|
||||
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">
|
||||
{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, 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 = 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) {
|
||||
navigate('system-messages');
|
||||
} else if (item.fileAssistant && item.topic && onTopicSelected) {
|
||||
onTopicSelected(item.topic);
|
||||
} else if (chatList && chatList.length && onTopicSelected) {
|
||||
onTopicSelected(item.id);
|
||||
} else {
|
||||
navigate('chat-standard');
|
||||
}
|
||||
};
|
||||
|
||||
const runQuickAction = item => {
|
||||
setMenuOpen(false);
|
||||
if (item.id === 'friends') return navigate('address-book');
|
||||
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');
|
||||
if (item.id === 'scan' && onOpenScanner) return onOpenScanner();
|
||||
setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`);
|
||||
};
|
||||
|
||||
return <div className="messages-product-page">
|
||||
<header className="messages-home-header">
|
||||
{offline ? <div className="messages-connection"><span className="messages-spinner" />加载中</div> : <h1>消息</h1>}
|
||||
<button type="button" className="messages-menu-button" onClick={() => setMenuOpen(true)} aria-label="更多功能"><Icon>menu</Icon></button>
|
||||
</header>
|
||||
<main className="messages-home-content">
|
||||
<label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label>
|
||||
{frequent.length > 0 ? <section className="messages-frequent">
|
||||
<h2>常联系的人</h2>
|
||||
<div className="messages-frequent-scroll">
|
||||
{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> : 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)}>
|
||||
<Avatar item={item} />
|
||||
<span className="messages-conversation-copy"><strong>{item.title}</strong><small>{item.summary}</small></span>
|
||||
<span className="messages-conversation-meta"><time>{item.time}</time>
|
||||
{item.unread ? <b>{item.unread > 99 ? '99+' : item.unread}</b> : item.muted ? <Icon>notifications_off</Icon> : null}
|
||||
</span>
|
||||
</button>)}
|
||||
{filtered.length === 0 ? <div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> : null}
|
||||
</section>
|
||||
</main>
|
||||
<BottomNavigation />
|
||||
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
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);
|
||||
if (item.id === 'copy' && navigator.clipboard) {
|
||||
navigator.clipboard.writeText('明天上午十点开项目会,请准时参加。').catch(() => {});
|
||||
}
|
||||
setToast(`${item.label}操作已选择`);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return <div className="message-actions-page">
|
||||
<header className="messages-sub-header"><button onClick={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>项目协作群</h1><span /></header>
|
||||
<main className="message-actions-chat">
|
||||
<p className="message-time">今天 10:24</p>
|
||||
<div className="message-demo-row"><Avatar item={{avatar: 'img/bkg/p09.jpg'}} /><button type="button" className="message-demo-bubble" onContextMenu={e => {e.preventDefault(); setVisible(true);}}>明天上午十点开项目会,请准时参加。</button></div>
|
||||
<div className="message-demo-row mine"><button type="button" className="message-demo-bubble mine" onContextMenu={e => {e.preventDefault(); setVisible(true);}}>收到,我会提前准备材料。</button><Avatar item={{avatar: 'img/bkg/p05.jpg'}} /></div>
|
||||
{selected ? <p className="message-operation-result">已选择:{MESSAGE_ACTIONS.find(item => item.id === selected).label}</p> : null}
|
||||
</main>
|
||||
<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">
|
||||
{actions.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
|
||||
<span>{item.label}</span><Icon>{item.icon}</Icon>
|
||||
</button>)}
|
||||
</div>
|
||||
</div> : null}
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SystemMessages({authenticated}) {
|
||||
const [items, setItems] = React.useState([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState('');
|
||||
const [toast, setToast] = React.useState('');
|
||||
|
||||
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={() => { if (!HashNavigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
|
||||
<main className="system-notification-list">
|
||||
{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_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>)}
|
||||
</main>
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function MessageCenterView(props) {
|
||||
switch (props.view) {
|
||||
case 'system-messages': return <SystemMessages authenticated={!!props.myUserId} />;
|
||||
case 'message-actions': return <MessageActions />;
|
||||
case 'messages-offline':
|
||||
case 'messages-menu':
|
||||
case 'messages-home':
|
||||
default: return <MessageHome {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
|
||||
export const MESSAGE_COMPOSER_VIEWS = [
|
||||
'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
|
||||
'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording',
|
||||
'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',
|
||||
'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',
|
||||
'forward-individual', 'forward-bundle'
|
||||
];
|
||||
|
||||
const MEMBERS = [
|
||||
{id: 'all', name: '所有人', note: '提醒群内所有成员', avatar: 'campaign'},
|
||||
{id: 'annie', name: '安妮', note: '产品设计组', image: 'img/bkg/p09.jpg'},
|
||||
{id: 'lizi', name: '栗子来了', note: '前端开发组', image: 'img/bkg/p15.jpg'},
|
||||
{id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'},
|
||||
{id: 'chen', name: '陈晨', note: '项目管理组', image: 'img/bkg/p05.jpg'}
|
||||
];
|
||||
|
||||
const TARGETS = [
|
||||
{id: 'file', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},
|
||||
{id: 'team', 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'},
|
||||
{id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'}
|
||||
];
|
||||
|
||||
const EMOJIS = ['😀', '😄', '😂', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '💡', '✅', '🙏', '💪', '🌟', '📌', '🚀'];
|
||||
|
||||
function Icon({children}) {
|
||||
return <i className="material-icons" aria-hidden="true">{children}</i>;
|
||||
}
|
||||
|
||||
function Avatar({item}) {
|
||||
return <span className="compose-avatar">
|
||||
{item.image ? <img src={item.image} alt="" /> : <Icon>{item.icon || item.avatar || 'person'}</Icon>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function Header({title = '产品研发群', subtitle = '18人', onMore}) {
|
||||
return <header className="chat-product-header">
|
||||
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<div><h1>{title}</h1>{subtitle ? <span>{subtitle}</span> : null}</div>
|
||||
<button type="button" onClick={onMore} aria-label="会话设置"><Icon>more_vert</Icon></button>
|
||||
</header>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="chat-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
function VoiceBubble({mine = false, duration = 8, unread = false}) {
|
||||
return <div className={'compose-message-row' + (mine ? ' mine' : '')}>
|
||||
{!mine ? <Avatar item={MEMBERS[1]} /> : null}
|
||||
<button type="button" className={'compose-voice-bubble' + (mine ? ' mine' : '')} aria-label={`播放${duration}秒语音`}>
|
||||
<Icon>graphic_eq</Icon><span className="compose-wave" /><strong>{duration}″</strong>{unread ? <i /> : null}
|
||||
</button>
|
||||
{mine ? <Avatar item={{image: 'img/bkg/p05.jpg'}} /> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TextBubble({mine = false, children, selected, onSelect}) {
|
||||
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
|
||||
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label={selected ? '取消选择' : '选择消息'}><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||
<div className={'compose-message-row' + (mine ? ' mine' : '')}>
|
||||
{!mine ? <Avatar item={MEMBERS[2]} /> : null}
|
||||
<div className={'compose-text-bubble' + (mine ? ' mine' : '')}>{children}</div>
|
||||
{mine ? <Avatar item={{image: 'img/bkg/p05.jpg'}} /> : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ImageBubble({progress, failed = false, selected, onSelect}) {
|
||||
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
|
||||
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择图片"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||
<div className="compose-message-row mine">
|
||||
<div className="compose-media-card"><img src="img/bkg/p22.jpg" alt="会议白板照片" />
|
||||
{progress < 100 || failed ? <div className="compose-media-state"><Icon>{failed ? 'refresh' : 'cloud_upload'}</Icon><strong>{failed ? '上传失败,点击重试' : `${progress}%`}</strong>{!failed ? <progress value={progress} max="100" /> : null}</div> : null}
|
||||
</div>
|
||||
<Avatar item={{image: 'img/bkg/p05.jpg'}} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VideoBubble() {
|
||||
return <div className="compose-message-row"><Avatar item={MEMBERS[3]} /><button type="button" className="compose-video-card" aria-label="播放视频"><img src="img/bkg/p23.jpg" alt="项目讨论视频封面" /><Icon>play_circle</Icon><small>00:24</small></button></div>;
|
||||
}
|
||||
|
||||
function FileBubble({progress = 100, name = '产品需求说明书.pdf', selected, onSelect}) {
|
||||
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
|
||||
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择文件"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||
<div className="compose-message-row mine"><div className="compose-file-card"><span className="compose-file-type">PDF</span><div><strong>{name}</strong><small>2.4 MB · {progress < 100 ? `上传中 ${progress}%` : '已发送'}</small>{progress < 100 ? <progress value={progress} max="100" /> : null}</div><Icon>{progress < 100 ? 'close' : 'download'}</Icon></div><Avatar item={{image: 'img/bkg/p05.jpg'}} /></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function BaseTimeline({mode, uploadProgress, selected, toggleSelected}) {
|
||||
const selectable = mode === 'message-multiselect' || mode === 'favorite-success';
|
||||
return <main className="chat-product-timeline compose-timeline">
|
||||
<p className="chat-time">今天 10:24</p>
|
||||
<TextBubble>上午的评审结论已经整理好了,大家有空看一下。</TextBubble>
|
||||
<TextBubble mine selected={selectable ? selected.includes('text') : undefined} onSelect={() => toggleSelected('text')}>好的,我补充一下交互细节。</TextBubble>
|
||||
{mode === 'mentions-message' ? <TextBubble><strong className="compose-mention">@安妮 @栗子来了</strong> 下午三点一起确认新版流程。</TextBubble> : null}
|
||||
{['media-uploading', 'media-gallery', 'media-retry'].includes(mode) ? <>
|
||||
<VoiceBubble duration={6} />
|
||||
<ImageBubble progress={mode === 'media-retry' ? 48 : uploadProgress} failed={mode === 'media-retry'} />
|
||||
{mode === 'media-gallery' ? <><VoiceBubble mine duration={12} unread /><VideoBubble /></> : null}
|
||||
</> : null}
|
||||
{['file-message', 'message-multiselect', 'favorite-success'].includes(mode) ? <FileBubble selected={selectable ? selected.includes('file') : undefined} onSelect={() => toggleSelected('file')} /> : null}
|
||||
{selectable ? <ImageBubble progress={100} selected={selected.includes('image')} onSelect={() => toggleSelected('image')} /> : null}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function EmojiPanel({onEmoji, onDelete}) {
|
||||
return <section className="compose-emoji-panel" aria-label="表情面板">
|
||||
<nav><button type="button" className="active" aria-label="最近使用"><Icon>schedule</Icon></button><button type="button" aria-label="笑脸"><Icon>sentiment_satisfied_alt</Icon></button><button type="button" aria-label="收藏表情"><Icon>favorite_border</Icon></button></nav>
|
||||
<div>{EMOJIS.map(emoji => <button type="button" key={emoji} onClick={() => onEmoji(emoji)}>{emoji}</button>)}</div>
|
||||
<button type="button" className="compose-emoji-delete" onClick={onDelete} aria-label="删除字符"><Icon>backspace</Icon></button>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Composer({mode, draft, setDraft, onSend, setMode}) {
|
||||
const voice = ['voice-ready', 'voice-hold', 'voice-recording'].includes(mode);
|
||||
const emoji = ['emoji-panel', 'emoji-message'].includes(mode);
|
||||
const keyDown = event => {
|
||||
if (event.key === '@' && mode !== 'mentions-message') window.setTimeout(() => navigate('mentions-select'), 0);
|
||||
};
|
||||
return <>
|
||||
<form className="compose-toolbar" onSubmit={event => {event.preventDefault(); onSend();}}>
|
||||
<button type="button" onClick={() => setMode(voice ? 'compose-keyboard' : 'voice-ready')} aria-label={voice ? '切换键盘' : '切换语音'}><Icon>{voice ? 'keyboard' : 'record_voice_over'}</Icon></button>
|
||||
{voice ? <button type="button" className="compose-hold-button" onPointerDown={() => setMode('voice-hold')} onPointerUp={() => setMode('media-uploading')}>按住说话</button> : <input autoFocus={mode === 'compose-keyboard'} value={draft} onChange={event => setDraft(event.target.value)} onKeyDown={keyDown} placeholder="输入消息" />}
|
||||
<button type="button" onClick={() => setMode(emoji ? 'compose-keyboard' : 'emoji-panel')} aria-label="表情"><Icon>sentiment_satisfied_alt</Icon></button>
|
||||
{draft.trim() && !voice ? <button type="submit" className="compose-send">发送</button> : <button type="button" onClick={() => navigate('upload-file')} aria-label="更多"><Icon>add_circle_outline</Icon></button>}
|
||||
</form>
|
||||
{emoji ? <EmojiPanel onEmoji={value => setDraft(draft + value)} onDelete={() => setDraft(draft.slice(0, -1))} /> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function VoiceOverlay({cancel = false}) {
|
||||
return <div className={'compose-record-overlay' + (cancel ? ' cancel' : '')} role="status">
|
||||
<Icon>{cancel ? 'delete_outline' : 'mic'}</Icon>
|
||||
<div className="compose-meter">{[2, 5, 8, 12, 9, 6, 11, 4].map((height, index) => <i key={index} style={{height: `${height * 2}px`}} />)}</div>
|
||||
<strong>{cancel ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong><small>00:08</small>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MemberPicker({complete}) {
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selected, setSelected] = React.useState(complete ? ['annie', 'lizi'] : []);
|
||||
const visible = MEMBERS.filter(item => item.name.includes(query) || item.note.includes(query));
|
||||
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||
return <div className="compose-picker-page">
|
||||
<header className="compose-picker-header"><button type="button" onClick={() => navigate('compose-keyboard')}><Icon>arrow_back_ios_new</Icon></button><h1>选择提醒人</h1><button type="button" className="text" disabled={!selected.length} onClick={() => {sessionStorage.setItem('im-mentions', JSON.stringify(selected)); navigate('mentions-message');}}>完成{selected.length ? `(${selected.length})` : ''}</button></header>
|
||||
<label className="compose-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索群成员" /></label>
|
||||
<p className="compose-section-title">群成员</p>
|
||||
<div className="compose-picker-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={'compose-checkbox' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UploadFilePage({onToast}) {
|
||||
const [files, setFiles] = React.useState([{name: '产品需求说明书.pdf', size: '2.4 MB', progress: 64}]);
|
||||
React.useEffect(() => {
|
||||
if (files.every(file => file.progress >= 100)) return undefined;
|
||||
const timer = window.setTimeout(() => setFiles(items => items.map(file => ({...file, progress: Math.min(100, file.progress + 6)}))), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [files]);
|
||||
return <div className="compose-upload-page"><Header title="上传文件" subtitle="最多选择 9 个文件" onMore={() => onToast('单个文件最大 100 MB')} />
|
||||
<main><label className="compose-upload-drop"><input type="file" multiple onChange={event => setFiles(Array.from(event.target.files).map(file => ({name: file.name, size: `${Math.max(1, file.size / 1024 / 1024).toFixed(1)} MB`, progress: 1})))} /><Icon>cloud_upload</Icon><strong>选择文件</strong><small>支持文档、压缩包及常用媒体格式</small></label>
|
||||
<section className="compose-upload-list"><h2>待发送文件</h2>{files.map(file => <div key={file.name}><span className="compose-file-type">{file.name.split('.').pop().slice(0, 4).toUpperCase()}</span><div><strong>{file.name}</strong><small>{file.size} · {file.progress < 100 ? `上传中 ${file.progress}%` : '上传完成'}</small><progress value={file.progress} max="100" /></div><button type="button" aria-label="移除文件" onClick={() => setFiles(items => items.filter(item => item.name !== file.name))}><Icon>close</Icon></button></div>)}</section>
|
||||
</main><footer><button type="button" disabled={!files.length} onClick={() => navigate('file-message')}>发送{files.length ? ` (${files.length})` : ''}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ForwardPage({mode, onToast}) {
|
||||
const multi = mode !== 'forward-select';
|
||||
const confirm = mode === 'forward-individual' || mode === 'forward-bundle';
|
||||
const [selected, setSelected] = React.useState(() => {
|
||||
if (!confirm) return [];
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem('im-forward-targets')) || ['team', 'annie'];
|
||||
} catch (_) {
|
||||
return ['team', 'annie'];
|
||||
}
|
||||
});
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [comment, setComment] = React.useState('');
|
||||
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : multi ? values.concat(id).slice(0, 9) : [id]);
|
||||
const visible = TARGETS.filter(item => item.name.includes(query) || item.note.includes(query));
|
||||
return <div className="compose-forward-page">
|
||||
<header className="compose-picker-header"><button type="button" onClick={() => navigate('message-multiselect')}><Icon>close</Icon></button><h1>{confirm ? (mode === 'forward-bundle' ? '合并转发' : '逐条转发') : '选择转发对象'}</h1>{!confirm ? <button type="button" className="text" onClick={() => navigate(multi ? 'forward-select' : 'forward-multiple')}>{multi ? '单选' : '多选'}</button> : <span />}</header>
|
||||
{!confirm ? <><label className="compose-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索会话或联系人" /></label><p className="compose-section-title">最近会话</p><div className="compose-picker-list">{visible.map(item => <button type="button" key={item.id} onClick={() => {toggle(item.id); if (!multi) {sessionStorage.setItem('im-forward-targets', JSON.stringify([item.id])); window.setTimeout(() => navigate('forward-individual'), 0);}}}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'compose-checkbox' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</div>{multi ? <footer className="compose-target-footer"><span>已选择 {selected.length}/9 个会话</span><button type="button" disabled={!selected.length} onClick={() => {sessionStorage.setItem('im-forward-targets', JSON.stringify(selected)); navigate('forward-individual');}}>下一步</button></footer> : null}</> : <main className="compose-forward-confirm"><section><h2>发送给</h2><div className="compose-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><section className="compose-forward-preview"><h2>{mode === 'forward-bundle' ? '产品研发群的聊天记录' : '将逐条发送 3 条消息'}</h2><p>栗子来了:上午的评审结论已经整理好了</p><p>我:好的,我补充一下交互细节。</p><p>我:[图片]</p></section><textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" /><div className="compose-forward-mode"><button type="button" className={mode === 'forward-individual' ? 'active' : ''} onClick={() => navigate('forward-individual')}>逐条转发</button><button type="button" className={mode === 'forward-bundle' ? 'active' : ''} onClick={() => navigate('forward-bundle')}>合并转发</button></div><button type="button" className="compose-confirm-send" onClick={() => {onToast(`已发送给 ${selected.length} 个会话`); window.setTimeout(() => navigate('chat-standard'), 900);}}>发送</button></main>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function MessageComposerView({view}) {
|
||||
const [mode, setMode] = React.useState(view);
|
||||
const [draft, setDraftState] = React.useState(() => sessionStorage.getItem('im-message-composer-draft') || (view === 'emoji-message' ? '收到啦 ' : ''));
|
||||
const [uploadProgress, setUploadProgress] = React.useState(view === 'media-uploading' ? 22 : 100);
|
||||
const [selected, setSelected] = React.useState(view === 'favorite-success' ? ['text', 'file', 'image'] : ['text', 'image']);
|
||||
const [toast, setToast] = React.useState(view === 'favorite-success' ? '已收藏 3 条消息' : '');
|
||||
const setDraft = value => {setDraftState(value); sessionStorage.setItem('im-message-composer-draft', value);};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'media-uploading' || uploadProgress >= 100) return undefined;
|
||||
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(100, value + 5)), 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, uploadProgress]);
|
||||
|
||||
if (view === 'mentions-select' || view === 'mentions-complete') return <MemberPicker complete={view === 'mentions-complete'} />;
|
||||
if (view === 'upload-file') return <><UploadFilePage onToast={setToast} /><Toast message={toast} onDone={() => setToast('')} /></>;
|
||||
if (['forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle'].includes(view)) return <><ForwardPage mode={view} onToast={setToast} /><Toast message={toast} onDone={() => setToast('')} /></>;
|
||||
|
||||
const toggleSelected = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||
const recording = mode === 'voice-hold' || mode === 'voice-recording';
|
||||
const multiselect = mode === 'message-multiselect' || mode === 'favorite-success';
|
||||
|
||||
return <div className="chat-product-page message-composer-page">
|
||||
<Header onMore={() => setToast('会话设置')} />
|
||||
{multiselect ? <div className="compose-selection-bar"><button type="button" onClick={() => navigate('chat-standard')}><Icon>close</Icon></button><strong>已选择 {selected.length} 条</strong><button type="button" onClick={() => setSelected(selected.length ? [] : ['text', 'file', 'image'])}>{selected.length ? '取消全选' : '全选'}</button></div> : null}
|
||||
<BaseTimeline mode={mode} uploadProgress={uploadProgress} selected={selected} toggleSelected={toggleSelected} />
|
||||
{multiselect ? <nav className="compose-batch-actions"><button type="button" disabled={!selected.length} onClick={() => navigate('forward-select')}><Icon>forward</Icon><span>转发</span></button><button type="button" disabled={!selected.length} onClick={() => {setMode('favorite-success'); setToast(`已收藏 ${selected.length} 条消息`);}}><Icon>bookmark_border</Icon><span>收藏</span></button><button type="button" disabled={!selected.length} onClick={() => {setSelected([]); setToast('已删除所选消息');}}><Icon>delete_outline</Icon><span>删除</span></button></nav> : <Composer mode={mode} draft={draft} setDraft={setDraft} setMode={setMode} onSend={() => {if (!draft.trim()) return; setToast('消息已发送'); setDraft('');}} />}
|
||||
{recording ? <VoiceOverlay cancel={false} /> : null}
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
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, 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', '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'
|
||||
];
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
|
||||
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}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="personal-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// 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>;
|
||||
}
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
// 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) { 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));
|
||||
};
|
||||
|
||||
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, 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}) {
|
||||
// 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: 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>;
|
||||
}
|
||||
|
||||
// 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 [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 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}) {
|
||||
// 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}) {
|
||||
// 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>
|
||||
{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>;
|
||||
}
|
||||
|
||||
// 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 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 [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: `前往【设置 - 通知管理】一键开启吧!`, 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 = () => {
|
||||
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, 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} 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 = <SettingsList onToast={setToast} onLogout={onLogout} />;
|
||||
else if (view === 'cache-clear-confirm') content = <CacheClear 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 === '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('')} /></>;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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, 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'
|
||||
];
|
||||
|
||||
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={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="support-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
// 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({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 [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>;
|
||||
}
|
||||
|
||||
// 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 [images, setImages] = React.useState([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const [diagnostics, setDiagnostics] = React.useState(false);
|
||||
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, hashParams, tinode}) {
|
||||
const [toast, setToast] = React.useState('');
|
||||
let content;
|
||||
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('')} /></>;
|
||||
}
|
||||
@@ -5,6 +5,17 @@ import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||
import SideNavbar from '../widgets/side-navbar.jsx';
|
||||
import TopicCommonView from './topic-common-view.jsx';
|
||||
import AuthOnboardingView from './auth-onboarding-view.jsx';
|
||||
import MessageCenterView from './message-center-view.jsx';
|
||||
import DirectMessageView from './direct-message-view.jsx';
|
||||
import MessageComposerView, {MESSAGE_COMPOSER_VIEWS} from './message-composer-view.jsx';
|
||||
import ContactManagementView, {CONTACT_MANAGEMENT_VIEWS} from './contact-management-view.jsx';
|
||||
import GroupManagementView, {GROUP_MANAGEMENT_VIEWS} from './group-management-view.jsx';
|
||||
import ContentLibraryView, {CONTENT_LIBRARY_VIEWS} from './content-library-view.jsx';
|
||||
import CallExperienceView, {CALL_EXPERIENCE_VIEWS} from './call-experience-view.jsx';
|
||||
import WorkplaceCollaborationView, {WORKPLACE_COLLABORATION_VIEWS} from './workplace-collaboration-view.jsx';
|
||||
import PersonalSettingsView, {PERSONAL_SETTINGS_VIEWS} from './personal-settings-view.jsx';
|
||||
import SecuritySupportView, {SECURITY_SUPPORT_VIEWS} from './security-support-view.jsx';
|
||||
|
||||
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
|
||||
import ContactsView from './contacts-view.jsx';
|
||||
@@ -117,7 +128,106 @@ class SidepanelView extends React.PureComponent {
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
|
||||
const view = this.props.state || (this.props.myUserId ? 'messages-home' : 'tenant');
|
||||
const chapterOneViews = ['tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'register',
|
||||
'reset', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password'];
|
||||
|
||||
if (chapterOneViews.includes(view)) {
|
||||
return <div id="sidepanel" className="auth-onboarding-panel">
|
||||
<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>;
|
||||
}
|
||||
|
||||
const chapterTwoViews = ['messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages'];
|
||||
if (chapterTwoViews.includes(view)) {
|
||||
const fullPage = !this.props.myUserId || view !== 'messages-home';
|
||||
return <div id="sidepanel" className={'message-center-panel' + (fullPage ? ' full-page' : '')}>
|
||||
<MessageCenterView
|
||||
view={view}
|
||||
connected={this.props.connected}
|
||||
chatList={this.props.chatList}
|
||||
myUserId={this.props.myUserId}
|
||||
onTopicSelected={this.props.onTopicSelected}
|
||||
onOpenScanner={this.props.onOpenScanner} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
const chapterThreeViews = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request',
|
||||
'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard',
|
||||
'chat-attachments', 'chat-mixed', 'chat-contact-card'];
|
||||
if (chapterThreeViews.includes(view)) {
|
||||
return <div id="sidepanel" className="direct-message-panel">
|
||||
<DirectMessageView view={view} key={view} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (MESSAGE_COMPOSER_VIEWS.includes(view)) {
|
||||
return <div id="sidepanel" className="message-composer-panel">
|
||||
<MessageComposerView view={view} key={view} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
|
||||
return <div id="sidepanel" className="contact-management-panel">
|
||||
<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} 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} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
|
||||
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} chatList={this.props.chatList} key={view} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (CALL_EXPERIENCE_VIEWS.includes(view)) {
|
||||
return <div id="sidepanel" className="call-experience-panel">
|
||||
<CallExperienceView view={view} key={view} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) {
|
||||
return <div id="sidepanel" className="workplace-collaboration-panel">
|
||||
<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} 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} hashParams={this.props.hashParams} tinode={this.props.tinode} key={view} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
let title, avatar, badges;
|
||||
if (view == 'contacts') {
|
||||
@@ -236,6 +346,7 @@ class SidepanelView extends React.PureComponent {
|
||||
<AccSecurityView
|
||||
tinode={this.props.tinode}
|
||||
incognitoMode={this.props.incognitoMode}
|
||||
onNavigate={this.props.onNavigate}
|
||||
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
|
||||
onUpdatePassword={this.props.onUpdatePassword}
|
||||
onLogout={this.props.onLogout}
|
||||
|
||||
+428
-19
@@ -11,14 +11,16 @@ 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'));
|
||||
const MessagesView = React.lazy(_ => import('./messages-view.jsx'));
|
||||
import SidepanelView from './sidepanel-view.jsx';
|
||||
|
||||
import { API_KEY, APP_NAME, DEFAULT_COLOR_SCHEME, DEFAULT_P2P_ACCESS_MODE, DEFAULT_TEXT_SIZE, FORWARDED_PREVIEW_LENGTH,
|
||||
import { API_KEY, APP_NAME, BASE_APP_NAME, DEFAULT_COLOR_SCHEME, DEFAULT_P2P_ACCESS_MODE, DEFAULT_TEXT_SIZE, FORWARDED_PREVIEW_LENGTH,
|
||||
LOGGING_ENABLED, MEDIA_BREAKPOINT, WAKE_UP_TICK, WAKE_UP_TIMEOUT } from '../config.js';
|
||||
import { CALL_STATE_NONE, CALL_STATE_OUTGOING_INITATED,
|
||||
CALL_STATE_INCOMING_RECEIVED, CALL_STATE_IN_PROGRESS,
|
||||
@@ -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);
|
||||
@@ -252,7 +270,8 @@ class TinodeWeb extends React.Component {
|
||||
errorAction: undefined,
|
||||
errorActionText: null,
|
||||
|
||||
sidePanelSelected: 'login',
|
||||
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 || {});
|
||||
@@ -441,7 +517,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
try {
|
||||
this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, APP_NAME));
|
||||
this.fcm = firebaseGetMessaging(firebaseInitApp(FIREBASE_INIT, BASE_APP_NAME));
|
||||
return navigator.serviceWorker.getRegistration('/service-worker.js').then(reg => {
|
||||
return reg || navigator.serviceWorker.register('/service-worker.js').then(reg => {
|
||||
this.checkForAppUpdate(reg);
|
||||
@@ -493,7 +569,7 @@ class TinodeWeb extends React.Component {
|
||||
return Notification.requestPermission().then(permission => {
|
||||
if (permission === 'granted') {
|
||||
return firebaseGetToken(fcm, {
|
||||
serviceWorkerRegistration: reg,
|
||||
serviceWorkerRegistration: sw,
|
||||
vapidKey: FIREBASE_INIT.messagingVapidKey
|
||||
}).then(token => {
|
||||
if (token) {
|
||||
@@ -576,8 +652,49 @@ class TinodeWeb extends React.Component {
|
||||
// Left-side panel selector.
|
||||
if (['acc_general','archive','blocked','contacts','cred','crop','edit','general',
|
||||
'newtpk','notif','register','reset','security','settings','support','wallpapers',
|
||||
'tenant','login','enterprise-register','enterprise-feedback','identity-check',
|
||||
'realname','realname-verified','realname-reminder','independent-password',
|
||||
'messages-home','messages-offline','messages-menu','message-actions','system-messages',
|
||||
'chat-inbound-request','chat-inbound-message','chat-outbound-request','chat-status',
|
||||
'chat-friend','chat-media','chat-blocked','chat-deleted','chat-standard',
|
||||
'chat-attachments','chat-mixed','chat-contact-card',
|
||||
'compose-keyboard','mentions-select','mentions-complete','mentions-message',
|
||||
'emoji-panel','emoji-message','voice-ready','voice-hold','voice-recording',
|
||||
'media-uploading','media-gallery','media-retry','upload-file','file-message',
|
||||
'message-multiselect','favorite-success','forward-select','forward-multiple',
|
||||
'forward-individual','forward-bundle',
|
||||
'address-book','friend-requests','add-friend','my-friend-qr','friend-search-result',
|
||||
'friend-verification','friend-profile','group-member-profile','friend-settings',
|
||||
'friend-settings-blocked','friend-delete-confirm','block-list','block-confirm','clear-chat-confirm',
|
||||
'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-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',
|
||||
'favorites-links','favorites-select','favorite-share','file-download','file-downloading',
|
||||
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
|
||||
'call-history','call-incoming','audio-call-ringing','audio-call-active','audio-call-muted',
|
||||
'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','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]);
|
||||
}
|
||||
@@ -600,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.
|
||||
@@ -852,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.
|
||||
@@ -886,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);
|
||||
}
|
||||
@@ -1272,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});
|
||||
@@ -1386,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,
|
||||
@@ -1604,8 +1776,12 @@ class TinodeWeb extends React.Component {
|
||||
localStorage.removeItem('auth-token');
|
||||
localStorage.removeItem('firebase-token');
|
||||
localStorage.removeItem('settings');
|
||||
if (this.state.firebaseToken) {
|
||||
firebaseDelToken(this.fcm);
|
||||
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 => {
|
||||
console.warn('Failed to delete FCM token on logout', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset color scheme.
|
||||
@@ -1620,12 +1796,14 @@ class TinodeWeb extends React.Component {
|
||||
this.tinode.onDisconnect = undefined;
|
||||
this.tinode.disconnect();
|
||||
} else {
|
||||
cleared = Promose.resolve();
|
||||
cleared = Promise.resolve();
|
||||
}
|
||||
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;
|
||||
@@ -1638,7 +1816,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
handleDeleteAccount() {
|
||||
this.tinode.delCurrentUser(true).then(_ => {
|
||||
return this.tinode.delCurrentUser(true).then(_ => {
|
||||
this.handleLogout();
|
||||
});
|
||||
}
|
||||
@@ -1703,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'));
|
||||
}
|
||||
@@ -1835,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,
|
||||
@@ -1852,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'});
|
||||
}
|
||||
@@ -2117,6 +2437,52 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
render() {
|
||||
const colorSchema = this.state.colorSchema !== 'auto' ? this.state.colorSchema : this.state.systemColorSchema;
|
||||
const activeProductView = this.state.sidePanelSelected || (this.state.myUserId ? 'messages-home' : 'tenant');
|
||||
const chapterOneViews = ['tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'register',
|
||||
'reset', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password'];
|
||||
const chapterOneActive = chapterOneViews.includes(activeProductView);
|
||||
const chapterTwoFullPage = ['messages-offline', 'messages-menu', 'message-actions', 'system-messages'].includes(activeProductView) ||
|
||||
(!this.state.myUserId && activeProductView === 'messages-home');
|
||||
const chapterThreeActive = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request',
|
||||
'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard',
|
||||
'chat-attachments', 'chat-mixed', 'chat-contact-card'].includes(activeProductView);
|
||||
const chapterFourActive = ['compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
|
||||
'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording',
|
||||
'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',
|
||||
'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',
|
||||
'forward-individual', 'forward-bundle'].includes(activeProductView);
|
||||
const chapterFiveActive = ['address-book', 'friend-requests', 'add-friend', 'my-friend-qr',
|
||||
'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile',
|
||||
'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list',
|
||||
'block-confirm', 'clear-chat-confirm'].includes(activeProductView);
|
||||
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-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);
|
||||
const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text',
|
||||
'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
|
||||
'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links',
|
||||
'history-images', 'history-videos'].includes(activeProductView);
|
||||
const callExperienceActive = ['call-history', 'call-incoming', 'audio-call-ringing',
|
||||
'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing',
|
||||
'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', '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', '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);
|
||||
return (
|
||||
<div id="app-container" ref={this.selfRef}>
|
||||
{this.state.contextMenuVisible ?
|
||||
@@ -2154,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}
|
||||
@@ -2169,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}
|
||||
@@ -2186,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}
|
||||
@@ -2228,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}
|
||||
@@ -2245,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}
|
||||
@@ -2265,7 +2667,7 @@ class TinodeWeb extends React.Component {
|
||||
showContextMenu={this.handleShowContextMenu} />
|
||||
: null}
|
||||
|
||||
{!this.state.displayMobile || (this.state.mobilePanel == 'topic-view' && !this.state.infoPanel) ?
|
||||
{!chapterOneActive && !chapterTwoFullPage && !chapterThreeActive && !chapterFourActive && !chapterFiveActive && !chapterSixActive && !chapterSevenActive && !callExperienceActive && !workplaceCollaborationActive && !personalSettingsActive && !securitySupportActive && (!this.state.displayMobile || (this.state.mobilePanel == 'topic-view' && !this.state.infoPanel)) ?
|
||||
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
|
||||
description="Message shown when component is loading"/></div>}>
|
||||
<MessagesView
|
||||
@@ -2278,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}
|
||||
@@ -2291,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}
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
import React from 'react';
|
||||
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import {
|
||||
uploadLargeFile, getMe, getFileUrl, getUserProfile,
|
||||
getWorkbench,
|
||||
getOrganizationDepartments, getOrganizationDepartmentMembers, getOrganizationMembers, searchOrganization,
|
||||
getMeetings, createMeeting, getMeeting, cancelMeeting, sendMeetingReminders, respondMeeting,
|
||||
getDailyReports, createDailyReport, getDailyReport, updateDailyDraft, submitDailyReport, getDailyStatistics, sendDailyReminders,
|
||||
getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, getMonthlyStatistics,
|
||||
getExecutiveMailboxConfig, submitExecutiveMailboxMessage
|
||||
} from '../lib/im-api.js';
|
||||
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||
|
||||
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 WORKPLACE_COLLABORATION_VIEWS = [
|
||||
'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'
|
||||
];
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
|
||||
function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) {
|
||||
return <header className="workplace-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" onClick={onAction} aria-label={actionLabel || '操作'} disabled={actionDisabled}><Icon>{action}</Icon></button> : <span />}
|
||||
</header>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
React.useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, 2100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
return message ? <div className="workplace-toast" role="status">{message}</div> : null;
|
||||
}
|
||||
|
||||
// "6月25日 周四(今天)" style used throughout this chapter's reference screenshots.
|
||||
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
function formatMeetingDate(d) {
|
||||
const now = new Date();
|
||||
const dayDiff = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()) -
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
|
||||
const suffix = dayDiff === 0 ? '(今天)' : dayDiff === 1 ? '(明天)' : dayDiff === -1 ? '(昨天)' : '';
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${WEEKDAYS[d.getDay()]}${suffix}`;
|
||||
}
|
||||
function formatTime(d) {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
// toISOString() is always UTC -- for any timezone ahead of UTC (e.g. China, UTC+8) this returned
|
||||
// the *previous* calendar day for the first several hours of every local day (00:00-08:00 local
|
||||
// still reads as "yesterday" in UTC), so opening the daily report editor in the early morning
|
||||
// loaded/redirected to yesterday's already-submitted report instead of starting today's. Match
|
||||
// toMonthInputValue's already-correct local-getters approach instead.
|
||||
function toDateInputValue(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Resolves a bare/prefixed user id to a display name, cached -- organization member/meeting
|
||||
// participant ids returned by different Ch.9 endpoints aren't consistently confirmed to carry the
|
||||
// 'usr' prefix, so try the id as given first and fall back to prefixing it before giving up.
|
||||
const nameCache = new Map();
|
||||
function resolveUserName(id, onDone) {
|
||||
if (!id) return;
|
||||
if (nameCache.has(id)) { onDone(nameCache.get(id)); return; }
|
||||
const withPrefix = id.startsWith('usr') ? id : 'usr' + id;
|
||||
getUserProfile(id).catch(() => getUserProfile(withPrefix)).then(p => {
|
||||
const name = (p && (p.display_name || p.nickname || p.public_account)) || id;
|
||||
nameCache.set(id, name);
|
||||
onDone(name);
|
||||
}).catch(() => { nameCache.set(id, id); onDone(id); });
|
||||
}
|
||||
|
||||
// GET /users/{id}/profile 400s ("malformed request") when {id} is the caller's own uid -- same
|
||||
// backend restriction AdminList already routes around by never calling this endpoint for the owner.
|
||||
// author_id/organizer_id fields here have no such alternative source, so the caller's own id has to
|
||||
// be special-cased directly rather than resolved through the same lookup as everyone else's.
|
||||
function useUserNames(ids, myUserId) {
|
||||
const [names, setNames] = React.useState({});
|
||||
React.useEffect(() => {
|
||||
(ids || []).forEach(id => {
|
||||
if (!id || names[id] !== undefined) return;
|
||||
const prefixed = id.startsWith('usr') ? id : 'usr' + id;
|
||||
if (myUserId && prefixed === myUserId) { setNames(v => ({...v, [id]: '我'})); return; }
|
||||
resolveUserName(id, name => setNames(v => ({...v, [id]: name})));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(ids), myUserId]);
|
||||
return names;
|
||||
}
|
||||
|
||||
// Real module codes confirmed live from GET /workbench (differ from the mock's guessed
|
||||
// monthly/daily/organization/meeting/mailbox). 'attendance' ("打卡") is a real, enabled module
|
||||
// with no reference screenshot or requirements text anywhere in this chapter -- routed to a
|
||||
// "not yet available" toast rather than guessing a feature with zero design reference.
|
||||
const APP_TONES = {organization: 'red', attendance: 'orange', meetings: 'blue', daily_reports: 'orange', monthly_reports: 'violet', executive_mailbox: 'green'};
|
||||
const APP_ICONS = {organization: 'account_tree', attendance: 'schedule', meetings: 'notifications_active', daily_reports: 'task_alt', monthly_reports: 'event_note', executive_mailbox: 'mail_outline'};
|
||||
const APP_ROUTES = {organization: 'organization-directory', meetings: 'meeting-attendee', daily_reports: 'daily-report-mine', monthly_reports: 'monthly-report-list', executive_mailbox: 'executive-mailbox'};
|
||||
|
||||
function Workbench({onToast}) {
|
||||
// `me` and `modules` decide whether this screen renders its real content or a blank/loading
|
||||
// placeholder -- caching just these two (not the per-icon `icons` map, which fills in gradually
|
||||
// regardless) removes the "blank workbench for a beat" flash every time the user tabs back to it.
|
||||
const [me] = useCachedData('me', getMe, []);
|
||||
const [modules, setModules] = React.useState(() => readCachedData('workbench-modules') || []);
|
||||
const [icons, setIcons] = React.useState({});
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
getWorkbench().then(data => {
|
||||
const list = (data && data.modules) || [];
|
||||
writeCachedData('workbench-modules', list);
|
||||
setModules(list);
|
||||
list.forEach(m => {
|
||||
if (m.icon_file_id) {
|
||||
getFileUrl(m.icon_file_id).then(url => url && setIcons(v => ({...v, [m.code]: url}))).catch(() => {});
|
||||
}
|
||||
});
|
||||
}).catch(err => setError(err.message || '工作台加载失败'));
|
||||
}, []);
|
||||
|
||||
const openApp = app => {
|
||||
if (APP_ROUTES[app.code]) { navigate(APP_ROUTES[app.code]); return; }
|
||||
onToast && onToast('该功能暂未开放');
|
||||
};
|
||||
|
||||
const profile = me && me.profile;
|
||||
return <div className="workplace-page workbench-page"><main>
|
||||
<section className="workbench-profile">
|
||||
{profile && profile.avatar_file_id ?
|
||||
<ImgFromFile fileId={profile.avatar_file_id} /> :
|
||||
<span className="workbench-avatar-fallback"><Icon>person</Icon></span>}
|
||||
<span><strong>{(profile && (profile.nickname || profile.public_account)) || '…'}</strong>
|
||||
<small>员工</small></span>
|
||||
<button type="button" aria-label="工作台设置" onClick={() => navigate('personal-center')}><Icon>tune</Icon></button>
|
||||
</section>
|
||||
<section className="workbench-banner"><div><strong>提高效率,从工作台开始</strong><small>Business applications start from the workbench</small></div><Icon>business_center</Icon></section>
|
||||
{error ? <p className="workplace-error">{error}</p> : null}
|
||||
<section className="workbench-apps" aria-label="办公应用">{modules.filter(m => m.enabled).map(app => <button type="button" key={app.code} onClick={() => openApp(app)}>
|
||||
{/* Namespaced ('tone-red', not 'red') because base.css's original Tinode styles already define
|
||||
bare `.red`/`.orange`/`.blue`/`.green` as generic `color: ... !important` text-color
|
||||
utilities -- an unprefixed tone class here collided with those and, since `!important`
|
||||
beats this span's own `color: #fff`, silently retinted 5 of these 6 icons to whichever
|
||||
generic accent color happened to share their tone's name (only 月报's 'violet' has no such
|
||||
collision, which is why it alone rendered the intended plain white) instead of the uniform
|
||||
white every icon here is meant to have -- same bug, same fix, as message-center-view.jsx's
|
||||
Avatar tone class. */}
|
||||
<span className={'tone-' + (APP_TONES[app.code] || 'blue')}>
|
||||
{icons[app.code] ? <img src={icons[app.code]} alt="" /> : <Icon>{APP_ICONS[app.code] || 'apps'}</Icon>}
|
||||
{app.unread_count ? <em>{app.unread_count > 99 ? '99+' : app.unread_count}</em> : null}
|
||||
</span>
|
||||
<strong>{app.name}</strong>
|
||||
</button>)}</section>
|
||||
</main><nav className="workplace-bottom-nav" aria-label="主导航">
|
||||
<button type="button" className="active"><Icon>dashboard</Icon><span>工作台</span></button>
|
||||
<button type="button" onClick={() => navigate('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button>
|
||||
</nav></div>;
|
||||
}
|
||||
|
||||
// Resolves a file id to an <img>, used for the few real photo/icon spots in this chapter
|
||||
// (workbench module icons, my own avatar) -- most Ch.9 screens use plain icons/text only,
|
||||
// confirmed against every reference screenshot, so this is intentionally not used everywhere.
|
||||
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} />;
|
||||
}
|
||||
|
||||
// Depth-based icon per docs/images/部门组织框架.png: level 1 = building, level 2 (collapsed) =
|
||||
// hollow diamond / (expanded) = filled blue diamond, level 3+ (a person, not a sub-department) =
|
||||
// hollow circle. The backend never returns a depth field (confirmed against organizationDepartmentPayload),
|
||||
// so depth is purely how far this node is from the root in the client's own recursion.
|
||||
function DeptRow({dept, depth, expanded, onToggle}) {
|
||||
const icon = depth === 0 ? 'business' : 'diamond';
|
||||
return <button type="button" className={`organization-department depth-${depth}${expanded ? ' expanded' : ''}`}
|
||||
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={onToggle}>
|
||||
<Icon>{icon}</Icon>
|
||||
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({depth === 0 ? '一级' : depth === 1 ? '二级' : `${depth + 1}级`})</strong></span>
|
||||
<Icon>{expanded ? 'expand_more' : 'chevron_right'}</Icon>
|
||||
</button>;
|
||||
}
|
||||
|
||||
// Search-result-only department row: no tree position to expand into (search returns a flat
|
||||
// match list, not a subtree), so unlike DeptRow this is informational, not clickable.
|
||||
function SearchDeptRow({dept}) {
|
||||
return <div className="organization-department depth-0" style={{paddingLeft: '1rem'}}>
|
||||
<Icon>business</Icon>
|
||||
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `(${dept.member_count}人)` : ''}</strong></span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PersonRow({person, depth, isMe, level}) {
|
||||
return <button type="button" className={`organization-person${isMe ? ' me' : ''}`}
|
||||
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={() => navigate('group-member-profile', {uid: person.user_id})}>
|
||||
<Icon>radio_button_unchecked</Icon>
|
||||
<span><strong>{person.display_name}({level}级)</strong></span>
|
||||
<Icon>chevron_right</Icon>
|
||||
</button>;
|
||||
}
|
||||
|
||||
// One department node: on first expand, fetches its child departments AND its direct members
|
||||
// (two separate calls -- there is no combined endpoint) and renders both, recursing for children.
|
||||
function DeptNode({dept, depth, myUserId}) {
|
||||
const [expanded, setExpanded] = React.useState(depth === 0);
|
||||
const [children, setChildren] = React.useState(null);
|
||||
const [members, setMembers] = React.useState(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const load = () => {
|
||||
if (children != null) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []),
|
||||
dept.member_count ? getOrganizationDepartmentMembers(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])
|
||||
]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); }).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); };
|
||||
React.useEffect(() => { if (expanded) load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||
|
||||
return <>
|
||||
<DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} />
|
||||
{expanded && loading ? <p className="organization-loading">加载中…</p> : null}
|
||||
{expanded && !loading ? <>
|
||||
{(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)}
|
||||
{(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1}
|
||||
level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)}
|
||||
</> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function durationLabel(startAt, endAt) {
|
||||
const mins = Math.round((new Date(endAt) - new Date(startAt)) / 60_000);
|
||||
if (mins <= 0) return '';
|
||||
if (mins % 60 === 0) return `${mins / 60}小时`;
|
||||
if (mins < 60) return `${mins}分钟`;
|
||||
return `${Math.floor(mins / 60)}小时${mins % 60}分钟`;
|
||||
}
|
||||
|
||||
const RESPONSE_LABEL = {accepted: '已接受', declined: '已拒绝', tentative: '待定'};
|
||||
|
||||
// Meeting responses already come back with names resolved server-side (organizer.display_name,
|
||||
// participant.display_name/participant_names[]) -- confirmed against a real created meeting's
|
||||
// response payload, so no separate getUserProfile lookups are needed here (unlike org-tree/report
|
||||
// authors, which only ever return bare ids).
|
||||
function MeetingCard({meeting, myUserId, onRespond, onToast}) {
|
||||
const organizerName = (meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name || meeting.organizer_id;
|
||||
const attendeeNames = (meeting.participants || []).map(p => p.display_name || p.user_id).join('、');
|
||||
const isOrganizer = meeting.organizer_id === myUserId;
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const respond = response => {
|
||||
setBusy(true);
|
||||
// Same stale-version hazard MeetingDetail's respond/cancel already guard against (every
|
||||
// participant's response bumps the meeting's version) -- this card's `meeting` prop is
|
||||
// whatever the list loaded, which can be behind if another invitee responded since.
|
||||
getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version)).then(_ => {
|
||||
onRespond();
|
||||
onToast(response === 'accepted' ? '已接受会议邀请' : response === 'declined' ? '已拒绝会议邀请' : '已标记为待定');
|
||||
}).catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return <article className="meeting-card">
|
||||
<header><span><small>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))}</small>
|
||||
<strong>会议主题:{meeting.title}</strong></span>
|
||||
<button type="button" className="meeting-more" onClick={() => navigate('meeting-detail', {meeting_id: meeting.meeting_id})}>查看更多<Icon>chevron_right</Icon></button>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>发起人</dt><dd>{organizerName}</dd></div>
|
||||
<div><dt>时间</dt><dd>{formatMeetingDate(new Date(meeting.start_at))}</dd></div>
|
||||
{meeting.location ? <div><dt>地点</dt><dd>{meeting.location}</dd></div> : null}
|
||||
<div><dt>参会人</dt><dd className="meeting-attendees">{attendeeNames || '无'}</dd></div>
|
||||
<div><dt>会议时间</dt><dd>{durationLabel(meeting.start_at, meeting.end_at)}</dd></div>
|
||||
</dl>
|
||||
{!isOrganizer ? (
|
||||
meeting.my_response && meeting.my_response !== 'pending' ?
|
||||
<footer><button type="button" className="meeting-responded" disabled>{RESPONSE_LABEL[meeting.my_response] || meeting.my_response}</button></footer> :
|
||||
<footer className="meeting-response">
|
||||
<button type="button" disabled={busy} onClick={() => respond('declined')}>拒绝</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</article>;
|
||||
}
|
||||
|
||||
function Meetings({onToast}) {
|
||||
const [meetings, setMeetings] = React.useState(() => readCachedData('meetings') || null);
|
||||
const [me] = useCachedData('me', getMe, []);
|
||||
const myUserId = me && me.user_id;
|
||||
|
||||
const load = () => getMeetings().then(d => {
|
||||
const list = d.meetings || d.items || [];
|
||||
writeCachedData('meetings', list);
|
||||
setMeetings(list);
|
||||
}).catch(() => setMeetings(prev => prev || []));
|
||||
// `load` has a concise arrow body, so it returns the whole promise chain -- passing it directly as
|
||||
// the effect callback made React treat that promise as an (invalid) cleanup return value.
|
||||
React.useEffect(() => { load(); }, []);
|
||||
|
||||
return <div className="workplace-page"><Header title="会议通知" action="add" actionLabel="发起会议" onAction={() => navigate('meeting-create')} />
|
||||
<main className="meeting-list">
|
||||
{meetings == null ? <p className="organization-loading">加载中…</p> :
|
||||
meetings.length === 0 ? <p className="organization-loading">暂无会议通知</p> :
|
||||
meetings.map(m => <MeetingCard key={m.meeting_id} meeting={m} myUserId={myUserId} onRespond={load} onToast={onToast} />)}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MeetingDetail({hashParams, onToast}) {
|
||||
const meetingId = hashParams && hashParams.meeting_id;
|
||||
const [meeting, setMeeting] = React.useState(() => (meetingId && readCachedData('meeting:' + meetingId)) || null);
|
||||
const [me] = useCachedData('me', getMe, []);
|
||||
const myUserId = me && me.user_id;
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
|
||||
const load = () => meetingId && getMeeting(meetingId).then(m => { writeCachedData('meeting:' + meetingId, m); setMeeting(m); }).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
|
||||
React.useEffect(() => {
|
||||
// This view is only ever keyed by view-name in sidepanel-view.jsx, not by meeting_id, so jumping
|
||||
// directly between two different meetings' details would otherwise briefly show the previous
|
||||
// meeting's cached data under the new id -- reseed explicitly from the new id's own cache entry.
|
||||
if (meetingId) setMeeting(readCachedData('meeting:' + meetingId) || null);
|
||||
load();
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [meetingId]);
|
||||
|
||||
if (!meeting) return <div className="workplace-page"><Header title="会议详情" back="meeting-attendee" /><main><p className="organization-loading">{loadError ? '会议信息加载失败' : '加载中…'}</p></main></div>;
|
||||
|
||||
const counts = {accepted: 0, pending: 0, declined: 0, tentative: 0};
|
||||
(meeting.participants || []).forEach(p => { counts[p.response] = (counts[p.response] || 0) + 1; });
|
||||
const isOrganizer = meeting.organizer_id === myUserId;
|
||||
|
||||
const remind = () => {
|
||||
setBusy(true);
|
||||
sendMeetingReminders(meeting.meeting_id).then(_ => onToast('已提醒未响应成员')).catch(err => onToast(err.message || '提醒失败')).finally(() => setBusy(false));
|
||||
};
|
||||
const cancel = () => {
|
||||
setBusy(true);
|
||||
// Every participant response bumps the meeting's version, so refetch immediately before
|
||||
// cancelling to avoid a stale-version rejection.
|
||||
getMeeting(meeting.meeting_id).then(fresh => cancelMeeting(fresh.meeting_id, fresh.version))
|
||||
.then(_ => { onToast('会议已取消'); load(); }).catch(err => onToast(err.message || '取消失败')).finally(() => setBusy(false));
|
||||
};
|
||||
const respond = response => {
|
||||
setBusy(true);
|
||||
getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version))
|
||||
.then(_ => { onToast(RESPONSE_LABEL[response] ? `已${RESPONSE_LABEL[response]}` : '已回应'); load(); })
|
||||
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return <div className="workplace-page"><Header title="会议详情" back="meeting-attendee" />
|
||||
<main className="meeting-list">
|
||||
<article className="meeting-card">
|
||||
<header><span><small>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))}</small>
|
||||
<strong>会议主题:{meeting.title}</strong></span></header>
|
||||
<dl>
|
||||
<div><dt>发起人</dt><dd>{(meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name || meeting.organizer_id}</dd></div>
|
||||
<div><dt>时间</dt><dd>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))} - {formatTime(new Date(meeting.end_at))}</dd></div>
|
||||
{meeting.location ? <div><dt>地点</dt><dd>{meeting.location}</dd></div> : null}
|
||||
<div><dt>会议时间</dt><dd>{durationLabel(meeting.start_at, meeting.end_at)}</dd></div>
|
||||
</dl>
|
||||
<ul className="meeting-participant-list">
|
||||
{(meeting.participants || []).map(p => <li key={p.user_id}>
|
||||
<span>{p.display_name || p.user_id}</span>
|
||||
<em className={p.response}>{RESPONSE_LABEL[p.response] || '待确认'}</em>
|
||||
</li>)}
|
||||
</ul>
|
||||
{!isOrganizer && meeting.permissions && meeting.permissions.can_respond && (!meeting.my_response || meeting.my_response === 'pending') ?
|
||||
<footer className="meeting-response">
|
||||
<button type="button" disabled={busy} onClick={() => respond('declined')}>无法参加</button>
|
||||
<button type="button" disabled={busy} onClick={() => respond('tentative')}>待定</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
|
||||
</footer> : null}
|
||||
</article>
|
||||
{isOrganizer ? <section className="meeting-status">
|
||||
<h2>参会确认</h2>
|
||||
<div><span><b>{counts.accepted}</b><small>已接受</small></span><span><b>{counts.pending}</b><small>待响应</small></span><span><b>{counts.declined}</b><small>已拒绝</small></span></div>
|
||||
<footer>
|
||||
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
|
||||
{meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'cancelled' ? <button type="button" className="danger" disabled={busy} onClick={cancel}>取消会议</button> : null}
|
||||
</footer>
|
||||
</section> : null}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MeetingCreate({myUserId, tinode, onToast}) {
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [date, setDate] = React.useState(toDateInputValue(new Date()));
|
||||
const [startTime, setStartTime] = React.useState('');
|
||||
const [endTime, setEndTime] = React.useState('');
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [candidates, setCandidates] = React.useState([]);
|
||||
const [selected, setSelected] = React.useState([]);
|
||||
const [names, setNames] = React.useState({});
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
getOrganizationMembers({query}).then(d => setCandidates(d.members || d.items || [])).catch(() => setCandidates([]));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
const toggle = uid => {
|
||||
setSelected(v => v.includes(uid) ? v.filter(x => x != uid) : v.concat(uid));
|
||||
const cand = candidates.find(c => c.user_id === uid);
|
||||
if (cand) setNames(v => ({...v, [uid]: cand.display_name}));
|
||||
};
|
||||
|
||||
const contacts = candidates.map(c => ({user: c.user_id, public: {fn: c.display_name}}));
|
||||
|
||||
const submit = () => {
|
||||
if (!title.trim() || !date || !startTime || !endTime || selected.length === 0) {
|
||||
onToast('请完善会议主题、时间与参会人');
|
||||
return;
|
||||
}
|
||||
const startAt = new Date(`${date}T${startTime}:00`).toISOString();
|
||||
const endAt = new Date(`${date}T${endTime}:00`).toISOString();
|
||||
setSubmitting(true);
|
||||
createMeeting({title: title.trim(), start_at: startAt, end_at: endAt, location: location.trim(), attendee_user_ids: selected})
|
||||
.then(_ => { onToast('会议已发起'); navigate('meeting-attendee'); })
|
||||
.catch(err => onToast(err.message || '发起会议失败'))
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return <div className="workplace-page report-editor-page"><Header title="发起会议" back="meeting-attendee" />
|
||||
<main>
|
||||
<label className="report-field"><span><b>*</b>会议主题</span>
|
||||
<input type="text" value={title} maxLength="80" onChange={e => setTitle(e.target.value)} placeholder="请输入会议主题" /></label>
|
||||
<label className="report-field"><span><b>*</b>日期</span><input type="date" value={date} onChange={e => setDate(e.target.value)} /></label>
|
||||
<div className="meeting-time-range">
|
||||
<label className="report-field"><span><b>*</b>开始时间</span><input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} /></label>
|
||||
<label className="report-field"><span><b>*</b>结束时间</span><input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} /></label>
|
||||
</div>
|
||||
<label className="report-field"><span>地点</span>
|
||||
<input type="text" value={location} maxLength="60" onChange={e => setLocation(e.target.value)} placeholder="请输入会议地点(可选)" /></label>
|
||||
<label className="report-field"><span><b>*</b>参会人</span>
|
||||
<input type="text" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索姓名添加参会人" /></label>
|
||||
{selected.length ? <div className="meeting-selected-attendees">
|
||||
{selected.map(uid => <span key={uid}>{names[uid] || uid}<i className="material-icons" onClick={() => toggle(uid)}>close</i></span>)}
|
||||
</div> : null}
|
||||
{tinode ? <ContactList tinode={tinode} contacts={contacts} myUserId={myUserId}
|
||||
emptyListMessage="搜索同事姓名" showOnline={false} showUnread={false} showContextMenu={false}
|
||||
topicSelected={selected} onTopicSelected={toggle} /> : null}
|
||||
</main>
|
||||
<footer className="workplace-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '发起中…' : '发起会议'}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function OrganizationDirectory({myUserId}) {
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [searchResults, setSearchResults] = React.useState(null);
|
||||
const [roots, setRoots] = React.useState(() => readCachedData('org-roots') || null);
|
||||
const [tenantName, setTenantName] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
getOrganizationDepartments('root').then(d => {
|
||||
const list = d.departments || d.items || [];
|
||||
writeCachedData('org-roots', list);
|
||||
setRoots(list);
|
||||
}).catch(() => setRoots(prev => prev || []));
|
||||
try {
|
||||
const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}');
|
||||
setTenantName(tenant.name || '');
|
||||
} catch (_) { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
// Was calling getOrganizationMembers({query}) -- besides that function's own now-fixed q/query
|
||||
// param bug, it only ever returns users, so "搜索成员或部门" (search members OR departments) could
|
||||
// never actually find a department. searchOrganization is the documented endpoint for this exact
|
||||
// box (GET /organization/search?q=&types=department,user) and already existed in im-api.js, matching
|
||||
// the real backend, but had no caller anywhere -- a correct implementation nothing used.
|
||||
React.useEffect(() => {
|
||||
if (!query.trim()) { setSearchResults(null); return; }
|
||||
const handle = window.setTimeout(() => {
|
||||
searchOrganization(query).then(d => setSearchResults(d.items || [])).catch(() => setSearchResults([]));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
return <div className="workplace-page"><Header title="部门组织框架" back="workbench" />
|
||||
<main className="organization-content">
|
||||
<label className="workplace-search"><Icon>search</Icon>
|
||||
<input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索成员或部门" />
|
||||
</label>
|
||||
{searchResults ?
|
||||
<section className="organization-tree">
|
||||
{searchResults.length === 0 ? <p className="organization-loading">未找到匹配的成员</p> :
|
||||
searchResults.map((item, idx) => item.type === 'department' ?
|
||||
<SearchDeptRow key={'d' + (item.department && item.department.department_id || idx)} dept={item.department} /> :
|
||||
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level="三"
|
||||
isMe={item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)} />)}
|
||||
</section> :
|
||||
<section className="organization-tree">
|
||||
{roots == null ? <p className="organization-loading">加载中…</p> :
|
||||
roots.length === 0 ? <p className="organization-loading">暂无组织架构数据</p> :
|
||||
roots.map(dept => <DeptNode key={dept.department_id} dept={dept} depth={0} myUserId={myUserId} />)}
|
||||
</section>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// The backend's daily report Content field is a flat string (confirmed against
|
||||
// store/types/reports.go -- no entries array exists server-side). docs/images/日报.png shows a
|
||||
// repeatable "起止时间 + 今日工作总结" block editor, so that's purely a front-end authoring
|
||||
// convenience: blocks are joined into one formatted string before being sent as `content`.
|
||||
function blocksToContent(blocks) {
|
||||
return blocks
|
||||
.filter(b => b.content.trim())
|
||||
.map(b => (b.start && b.end ? `${b.start}-${b.end}\n` : '') + b.content.trim())
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function DailyReportEditor({onToast}) {
|
||||
const today = toDateInputValue(new Date());
|
||||
const cacheKey = 'daily-report-editor:' + today;
|
||||
// This composer sits in a side panel next to chat -- a user plausibly starts today's report,
|
||||
// switches away to answer a message, and comes back mid-edit, so seed from cache (not just the
|
||||
// read-only hub pages) to avoid the blocks textarea flashing back to empty on that round trip.
|
||||
// createDailyReport() below always still runs and overwrites this with the server's own state,
|
||||
// so a stale/wrong seed here self-corrects within one request -- same "revalidate in background"
|
||||
// shape as everywhere else, just with a same-shape object rather than a single field.
|
||||
const cached = readCachedData(cacheKey);
|
||||
const [reportId, setReportId] = React.useState(cached ? cached.reportId : null);
|
||||
const [version, setVersion] = React.useState(cached ? cached.version : null);
|
||||
// The debounced autosave below reads `version` from inside a setTimeout scheduled by an effect
|
||||
// keyed on `blocks`/`reportId` (not `version`) -- if a save request is still in flight when the
|
||||
// next edit re-schedules the timer, the new timer's closure captures whatever `version` was at
|
||||
// that moment, which can be one save behind the real server version by the time it actually
|
||||
// fires (the earlier save's response, which bumps `version`, can land after the new timer was
|
||||
// created but before it runs). A ref always reflects the latest value regardless of when the
|
||||
// closure was created, so reads through it can't be stale the way a captured state variable can.
|
||||
const versionRef = React.useRef(cached ? cached.version : null);
|
||||
const setVersionBoth = v => { versionRef.current = v; setVersion(v); };
|
||||
const [blocks, setBlocks] = React.useState(cached ? cached.blocks : [{start: '', end: '', content: ''}]);
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
const [saveError, setSaveError] = React.useState(false);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
// Serializes every draft save (the debounced autosave below AND submit()'s own pre-submit save)
|
||||
// through one chain, so two overlapping requests can never both read the same not-yet-updated
|
||||
// versionRef and race the server -- confirmed live: clicking submit right as/after the debounce
|
||||
// timer's own autosave fires can otherwise send two saves built from the same stale version, and
|
||||
// whichever the server sees second comes back 409 VERSION_CONFLICT, leaving the report un-submitted
|
||||
// with no obvious explanation. Reopening a cached draft and only touching a little before submitting
|
||||
// (now a normal flow, where blank-every-time wasn't) makes this window much easier to hit than before.
|
||||
const saveChainRef = React.useRef(Promise.resolve());
|
||||
const draftTimerRef = React.useRef(null);
|
||||
const saveDraft = React.useCallback(nextBlocks => {
|
||||
const run = () => updateDailyDraft(reportId, blocksToContent(nextBlocks), versionRef.current, []).then(r => {
|
||||
setVersionBoth(r.version); setSaved(true); setSaveError(false);
|
||||
writeCachedData(cacheKey, {reportId, version: r.version, blocks: nextBlocks});
|
||||
return r;
|
||||
});
|
||||
const next = saveChainRef.current.then(run);
|
||||
saveChainRef.current = next.catch(() => {});
|
||||
return next;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reportId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
createDailyReport(today).then(r => {
|
||||
// createDailyReport is idempotent -- reopening this editor for a day whose report was already
|
||||
// submitted returns that same (now non-draft) report instead of erroring. The draft-save
|
||||
// endpoint's UPDATE is scoped to `WHERE status='draft'` (im_be's TenantDailyReportSaveDraft),
|
||||
// so every autosave against a submitted report's affected-rows count is 0 -> a 409 that used
|
||||
// to be silently swallowed. can_edit is the backend's own real-time signal for this (same
|
||||
// status check), so redirect to the read-only detail view instead of handing the user a
|
||||
// composer that looks editable but can never actually save.
|
||||
if (r.permissions && r.permissions.can_edit === false) { navigate('daily-report-detail', {report_id: r.report_id}); return; }
|
||||
const nextBlocks = r.content ? [{start: '', end: '', content: r.content}] : [{start: '', end: '', content: ''}];
|
||||
setReportId(r.report_id);
|
||||
setVersionBoth(r.version);
|
||||
setBlocks(nextBlocks);
|
||||
writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, blocks: nextBlocks});
|
||||
}).catch(err => onToast(err.message || '创建日报失败'));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!reportId) return undefined;
|
||||
setSaved(false);
|
||||
draftTimerRef.current = window.setTimeout(() => {
|
||||
draftTimerRef.current = null;
|
||||
saveDraft(blocks).catch(() => setSaveError(true));
|
||||
}, 600);
|
||||
return () => window.clearTimeout(draftTimerRef.current);
|
||||
}, [blocks, reportId, saveDraft]);
|
||||
|
||||
const updateBlock = (idx, field, value) => setBlocks(v => v.map((b, i) => i === idx ? {...b, [field]: value} : b));
|
||||
const addBlock = () => setBlocks(v => v.concat({start: '', end: '', content: ''}));
|
||||
const removeBlock = idx => setBlocks(v => v.length > 1 ? v.filter((_, i) => i !== idx) : v);
|
||||
|
||||
const submit = () => {
|
||||
const content = blocksToContent(blocks);
|
||||
if (!content.trim()) { onToast('请填写今日工作总结'); return; }
|
||||
setSubmitting(true);
|
||||
if (draftTimerRef.current) { window.clearTimeout(draftTimerRef.current); draftTimerRef.current = null; }
|
||||
saveDraft(blocks).then(r => submitDailyReport(reportId, r.version))
|
||||
.then(_ => { onToast('日报已提交'); window.setTimeout(() => navigate('daily-report-detail', {report_id: reportId}), 500); })
|
||||
.catch(err => onToast(err.message || '提交失败'))
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return <div className="workplace-page report-editor-page"><Header title="日报" back="daily-report-mine" />
|
||||
<main>
|
||||
{blocks.map((block, idx) => <section key={idx} className="daily-report-block">
|
||||
<div className="daily-report-block-header"><strong>日报</strong>
|
||||
{blocks.length > 1 ? <button type="button" onClick={() => removeBlock(idx)} aria-label="删除"><Icon>close</Icon></button> : null}
|
||||
</div>
|
||||
<label className="report-field"><span>起止时间</span><small className="daily-report-date">{formatMeetingDate(new Date(today))}</small>
|
||||
<div className="meeting-time-range">
|
||||
<input type="time" value={block.start} onChange={e => updateBlock(idx, 'start', e.target.value)} />
|
||||
<span>至</span>
|
||||
<input type="time" value={block.end} onChange={e => updateBlock(idx, 'end', e.target.value)} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="report-field"><span>今日工作总结</span>
|
||||
<textarea value={block.content} maxLength="1000" placeholder="请输入" onChange={e => updateBlock(idx, 'content', e.target.value)} />
|
||||
<small>{block.content.length}/1000</small>
|
||||
</label>
|
||||
</section>)}
|
||||
<button type="button" className="daily-report-add-block" onClick={addBlock}><Icon>add</Icon>添加时间段</button>
|
||||
<p className="report-draft-status">{saveError ? '草稿保存失败,请检查网络后重新编辑' : saved ? '草稿已自动保存' : '正在保存草稿…'}</p>
|
||||
</main>
|
||||
<footer className="workplace-submit-bar"><button type="button" disabled={submitting || !reportId} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const STATUS_LABEL = {submitted: '已提交', draft: '未提交'};
|
||||
|
||||
function DailyReportCard({report, names, team}) {
|
||||
return <article>
|
||||
<header><span><strong>{names[report.author_id] || report.author_id}</strong><small className="role-badge">{team ? (report.department_name || '员工') : '员工'}</small></span>
|
||||
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></header>
|
||||
<p>{report.content_short || '(未填写内容)'}</p>
|
||||
<footer><span><Icon>schedule</Icon>{report.report_date}</span>
|
||||
<button type="button" onClick={() => navigate('daily-report-detail', {report_id: report.report_id})}>查看详情<Icon>chevron_right</Icon></button></footer>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function DailyReportList({team, myUserId, onToast}) {
|
||||
const cacheKey = 'daily-reports:' + (team ? 'managed' : 'mine');
|
||||
const statsCacheKey = 'daily-stats:' + toDateInputValue(new Date());
|
||||
// Seed from cache so re-entering "我的日报"/"团队统计" (a real user flow: check the list, go fill
|
||||
// in today's report, come back) doesn't blank the list out while the identical request re-runs.
|
||||
const [reports, setReports] = React.useState(() => readCachedData(cacheKey) || null);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [stats, setStats] = React.useState(() => (team && readCachedData(statsCacheKey)) || null);
|
||||
const ids = (reports || []).map(r => r.author_id);
|
||||
const names = useUserNames(ids, myUserId);
|
||||
|
||||
const load = () => {
|
||||
getDailyReports({scope: team ? 'managed' : 'mine'}).then(d => {
|
||||
const list = d.reports || d.items || [];
|
||||
writeCachedData(cacheKey, list);
|
||||
setReports(list);
|
||||
}).catch(() => setReports(prev => prev || []));
|
||||
if (team) getDailyStatistics(toDateInputValue(new Date())).then(s => { writeCachedData(statsCacheKey, s); setStats(s); }).catch(() => {});
|
||||
};
|
||||
React.useEffect(load, [team]);
|
||||
|
||||
const filtered = (reports || []).filter(r => {
|
||||
const hay = team ? `${names[r.author_id] || ''}${r.department_name || ''}` : r.report_date;
|
||||
return hay.includes(query);
|
||||
});
|
||||
|
||||
const remindMissing = () => {
|
||||
sendDailyReminders(toDateInputValue(new Date())).then(_ => onToast('已提醒未提交成员'))
|
||||
.catch(err => onToast(err.message || '提醒失败'));
|
||||
};
|
||||
|
||||
return <div className="workplace-page report-list-page">
|
||||
<Header title="日报" action="add" actionLabel="填写日报" onAction={() => navigate('daily-report-editor')} back={team ? 'daily-report-mine' : 'workbench'} />
|
||||
<main>
|
||||
{team && stats ? <section className="report-statistics">
|
||||
<header><span><small>{formatMeetingDate(new Date())}</small><strong>全公司</strong></span>
|
||||
<button type="button" onClick={() => navigate('daily-report-mine')}>我的日报</button></header>
|
||||
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
|
||||
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
|
||||
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
|
||||
<button type="button" onClick={remindMissing}><Icon>notifications_active</Icon>提醒未提交成员</button>
|
||||
</section> : null}
|
||||
<label className="workplace-search"><Icon>search</Icon>
|
||||
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名/部门' : '搜索日期'} /></label>
|
||||
{!team ? <nav className="report-role-switch">
|
||||
<button type="button" className="active">我的日报</button>
|
||||
<button type="button" onClick={() => navigate('daily-report-team')}>团队统计</button>
|
||||
</nav> : null}
|
||||
<section className="report-cards">
|
||||
{reports == null ? <p className="organization-loading">加载中…</p> :
|
||||
filtered.length === 0 ? <p className="organization-loading">暂无日报</p> :
|
||||
filtered.map(r => <DailyReportCard key={r.report_id} report={r} names={names} team={team} />)}
|
||||
</section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DailyReportDetail({hashParams, myUserId, onToast}) {
|
||||
const reportId = hashParams && hashParams.report_id;
|
||||
const [report, setReport] = React.useState(() => (reportId && readCachedData('daily-report:' + reportId)) || null);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const names = useUserNames(report ? [report.author_id] : [], myUserId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!reportId) return;
|
||||
// Keyed only by view-name upstream (not report_id), so reseed per-id here rather than relying
|
||||
// on the initializer -- see the identical MeetingDetail comment for why that matters.
|
||||
setReport(readCachedData('daily-report:' + reportId) || null);
|
||||
getDailyReport(reportId).then(r => { writeCachedData('daily-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '日报加载失败'); setLoadError(true); });
|
||||
}, [reportId]);
|
||||
if (!report) return <div className="workplace-page"><Header title="查看详情" back="daily-report-mine" /><main><p className="organization-loading">{loadError ? '日报加载失败' : '加载中…'}</p></main></div>;
|
||||
|
||||
return <div className="workplace-page"><Header title="查看详情" back="daily-report-mine" />
|
||||
<main className="report-detail">
|
||||
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
|
||||
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
|
||||
<section><small><Icon>schedule</Icon>{report.report_date}</small></section>
|
||||
<section><h3>今日工作总结</h3><p>{report.content || '未填写'}</p></section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function toMonthInputValue(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }
|
||||
function formatMonthLabel(month) {
|
||||
const [y, m] = month.split('-');
|
||||
return `${y} 年 ${parseInt(m, 10)} 月`;
|
||||
}
|
||||
|
||||
// docs/images/月报(1).png shows the second field labeled "其他事项" (optional-looking, no
|
||||
// asterisk) but the backend's only real second field is `next_plan` (store/types/reports.go),
|
||||
// declared required in the backend's own advertised contract (monthlyReportDetailPayload:
|
||||
// next_plan_required:true). The 月报 image set also had a confirmed content mix-up elsewhere
|
||||
// (月报.png's list content is verbatim 日报 demo text), so it's trusted less than usual here --
|
||||
// labeling this field by what it actually stores/is required to avoid silently mislabeling
|
||||
// "next month's plan" as generic "other matters".
|
||||
function MonthlyReportEditor({onToast}) {
|
||||
const month = toMonthInputValue(new Date());
|
||||
const cacheKey = 'monthly-report-editor:' + month;
|
||||
// Same rationale as DailyReportEditor's cache seed -- see the comment there.
|
||||
const cached = readCachedData(cacheKey);
|
||||
const [reportId, setReportId] = React.useState(cached ? cached.reportId : null);
|
||||
const [version, setVersion] = React.useState(cached ? cached.version : null);
|
||||
// See the matching comment in DailyReportEditor -- a ref avoids the debounced-save-in-flight
|
||||
// stale-version race that a captured `version` state variable would be exposed to.
|
||||
const versionRef = React.useRef(cached ? cached.version : null);
|
||||
const setVersionBoth = v => { versionRef.current = v; setVersion(v); };
|
||||
const [summary, setSummary] = React.useState(cached ? cached.summary : '');
|
||||
const [plan, setPlan] = React.useState(cached ? cached.plan : '');
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
const [saveError, setSaveError] = React.useState(false);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
// Same serialized-save fix as DailyReportEditor -- see the comment there. Live-reproduced here: a
|
||||
// real submit attempt raced the debounced autosave, both built from version 9, one landed and bumped
|
||||
// the server to version 10, the other came back 409 VERSION_CONFLICT and the report never actually
|
||||
// got submitted.
|
||||
const saveChainRef = React.useRef(Promise.resolve());
|
||||
const draftTimerRef = React.useRef(null);
|
||||
const saveDraft = React.useCallback((s, p) => {
|
||||
const run = () => updateMonthlyDraft(reportId, s, p, versionRef.current, []).then(r => {
|
||||
setVersionBoth(r.version); setSaved(true); setSaveError(false);
|
||||
writeCachedData(cacheKey, {reportId, version: r.version, summary: s, plan: p});
|
||||
return r;
|
||||
});
|
||||
const next = saveChainRef.current.then(run);
|
||||
saveChainRef.current = next.catch(() => {});
|
||||
return next;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reportId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
createMonthlyReport(month).then(r => {
|
||||
// Same already-submitted-report trap as DailyReportEditor -- see the comment there.
|
||||
if (r.permissions && r.permissions.can_edit === false) { navigate('monthly-report-detail', {report_id: r.report_id}); return; }
|
||||
const nextSummary = r.work_summary || ''; const nextPlan = r.next_plan || '';
|
||||
setReportId(r.report_id); setVersionBoth(r.version);
|
||||
setSummary(nextSummary); setPlan(nextPlan);
|
||||
writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, summary: nextSummary, plan: nextPlan});
|
||||
}).catch(err => onToast(err.message || '创建月报失败'));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!reportId) return undefined;
|
||||
setSaved(false);
|
||||
draftTimerRef.current = window.setTimeout(() => {
|
||||
draftTimerRef.current = null;
|
||||
saveDraft(summary, plan).catch(() => setSaveError(true));
|
||||
}, 600);
|
||||
return () => window.clearTimeout(draftTimerRef.current);
|
||||
}, [summary, plan, reportId, saveDraft]);
|
||||
|
||||
const submit = () => {
|
||||
if (!summary.trim() || !plan.trim()) { onToast('请完成必填内容'); return; }
|
||||
setSubmitting(true);
|
||||
if (draftTimerRef.current) { window.clearTimeout(draftTimerRef.current); draftTimerRef.current = null; }
|
||||
saveDraft(summary, plan).then(r => submitMonthlyReport(reportId, r.version))
|
||||
.then(_ => { onToast('月报已提交'); window.setTimeout(() => navigate('monthly-report-detail', {report_id: reportId}), 500); })
|
||||
.catch(err => onToast(err.message || '提交失败'))
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return <div className="workplace-page report-editor-page"><Header title="月报" back="monthly-report-list" />
|
||||
<main>
|
||||
<section className="daily-report-block-header"><strong>月报汇总</strong></section>
|
||||
<label className="report-field"><span><b>*</b>本月工作总结</span>
|
||||
<textarea value={summary} maxLength="1000" placeholder="请输入" onChange={e => setSummary(e.target.value)} /><small>{summary.length}/1000</small></label>
|
||||
<label className="report-field"><span><b>*</b>下月工作计划</span>
|
||||
<textarea value={plan} maxLength="1000" placeholder="请输入" onChange={e => setPlan(e.target.value)} /><small>{plan.length}/1000</small></label>
|
||||
<p className="report-draft-status">{saveError ? '草稿保存失败,请检查网络后重新编辑' : saved ? '草稿已自动保存' : '正在保存草稿…'}</p>
|
||||
</main>
|
||||
<footer className="workplace-submit-bar"><button type="button" disabled={submitting || !reportId} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MonthlyReportList({onToast}) {
|
||||
// Not routed through the shared useCachedData hook (unlike this file's other list/detail pages) --
|
||||
// that hook's own fetch failure is silently swallowed (see use-cached-data.js's catch) with no way
|
||||
// for a caller to observe it, only a second `loading` return value nothing here destructures. A
|
||||
// plain local fetch lets this page track its own error state instead.
|
||||
const [reports, setReports] = React.useState(() => readCachedData('monthly-reports:mine') || null);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
getMonthlyReports({scope: 'mine'}).then(d => {
|
||||
const list = d.reports || d.items || [];
|
||||
writeCachedData('monthly-reports:mine', list);
|
||||
setReports(list);
|
||||
}).catch(err => { onToast(err.message || '月报列表加载失败'); setLoadError(true); });
|
||||
}, []);
|
||||
|
||||
const filtered = (reports || []).filter(r => (r.month || '').includes(query) || (r.work_summary_short || '').includes(query));
|
||||
|
||||
return <div className="workplace-page report-list-page">
|
||||
<Header title="月报" action="add" actionLabel="填写月报" onAction={() => navigate('monthly-report-editor')} />
|
||||
<main>
|
||||
<label className="workplace-search"><Icon>search</Icon>
|
||||
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索月份或内容" /></label>
|
||||
<section className="report-cards">
|
||||
{reports == null ? <p className="organization-loading">{loadError ? '月报列表加载失败' : '加载中…'}</p> :
|
||||
filtered.length === 0 ? <p className="organization-loading">暂无月报</p> :
|
||||
filtered.map(r => <button type="button" key={r.report_id}
|
||||
onClick={() => navigate((r.action || (r.status === 'submitted' ? 'view' : 'edit')) === 'view' ? 'monthly-report-detail' : 'monthly-report-editor', {report_id: r.report_id})}>
|
||||
<span className="report-month"><Icon>calendar_month</Icon></span>
|
||||
<span><strong>{formatMonthLabel(r.month)}</strong><small>工作总结与下月计划</small></span>
|
||||
<em className={r.status}>{STATUS_LABEL[r.status] || r.status}</em>
|
||||
<Icon>chevron_right</Icon>
|
||||
</button>)}
|
||||
</section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MonthlyReportDetail({hashParams, myUserId, onToast}) {
|
||||
const reportId = hashParams && hashParams.report_id;
|
||||
const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const names = useUserNames(report ? [report.author_id] : [], myUserId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!reportId) return;
|
||||
setReport(readCachedData('monthly-report:' + reportId) || null);
|
||||
getMonthlyReport(reportId).then(r => { writeCachedData('monthly-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '月报加载失败'); setLoadError(true); });
|
||||
}, [reportId]);
|
||||
if (!report) return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
|
||||
|
||||
return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" />
|
||||
<main className="report-detail">
|
||||
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
|
||||
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
|
||||
<section><small><Icon>schedule</Icon>{formatMonthLabel(report.month)}</small></section>
|
||||
<section><h3>本月工作总结</h3><p>{report.work_summary || '未填写'}</p></section>
|
||||
<section><h3>下月工作计划</h3><p>{report.next_plan || '未填写'}</p></section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// docs/images/总经理信箱.png has no subject input at all -- just 反馈内容 + 附件 + 实名/匿名. The
|
||||
// backend's submission body does accept a real, stored `subject` (store/types/mailbox.go), but
|
||||
// never validates it, so a derived summary is sent without exposing a box the image doesn't show.
|
||||
function deriveSubject(content) {
|
||||
const first = content.trim().split('\n')[0];
|
||||
return first.length > 30 ? first.slice(0, 30) + '…' : first;
|
||||
}
|
||||
|
||||
function ExecutiveMailbox({tinode, noticeOnly, onToast}) {
|
||||
const [config, setConfig] = React.useState(() => readCachedData('executive-mailbox-config') || null);
|
||||
const [content, setContent] = React.useState('');
|
||||
const [anonymous, setAnonymous] = React.useState(null);
|
||||
const [attachments, setAttachments] = React.useState([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = React.useState(!!noticeOnly);
|
||||
const fileInputRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
getExecutiveMailboxConfig().then(c => { writeCachedData('executive-mailbox-config', c); setConfig(c); })
|
||||
// Keep a prior good value on a transient failure rather than clobbering it to the disabled
|
||||
// placeholder -- only fall back to {enabled:false} when there's truly nothing cached yet.
|
||||
.catch(() => setConfig(prev => prev || {enabled: false}));
|
||||
}, []);
|
||||
|
||||
const pickFile = () => fileInputRef.current && fileInputRef.current.click();
|
||||
const handleFile = event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
event.target.value = '';
|
||||
if (!file || !tinode) return;
|
||||
setUploading(true);
|
||||
uploadLargeFile(tinode, file).then(({fileId}) => {
|
||||
setAttachments(v => v.concat({fileId, name: file.name}));
|
||||
}).catch(err => onToast(err.message || '附件上传失败')).finally(() => setUploading(false));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!content.trim()) { onToast('请填写反馈内容'); return; }
|
||||
if (config && config.allow_anonymous && !anonymous) { onToast('请选择实名反馈或匿名反馈'); return; }
|
||||
setSubmitting(true);
|
||||
submitExecutiveMailboxMessage({
|
||||
subject: deriveSubject(content),
|
||||
content: content.trim(),
|
||||
anonymous: anonymous === 'anonymous',
|
||||
attachment_file_ids: attachments.map(a => a.fileId)
|
||||
}).then(_ => {
|
||||
onToast('反馈成功');
|
||||
setContent(''); setAttachments([]); setAnonymous(null);
|
||||
}).catch(err => onToast(err.message || '提交失败')).finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
if (config && !config.enabled) {
|
||||
return <div className="workplace-page mailbox-page"><Header title="总经理信箱" back="workbench" />
|
||||
<main><p className="organization-loading">总经理信箱当前不可用</p></main></div>;
|
||||
}
|
||||
|
||||
return <div className="workplace-page mailbox-page">
|
||||
<Header title="总经理信箱" back="workbench" action={config && config.notice ? 'info_outline' : null} actionLabel="查看使用说明" onAction={() => setNoticeOpen(true)} />
|
||||
<main>
|
||||
<label className="mailbox-content"><span>反馈内容</span>
|
||||
<textarea value={content} maxLength={(config && config.max_length) || 1000} placeholder="请输入" onChange={e => setContent(e.target.value)} />
|
||||
<small>{content.length}/{(config && config.max_length) || 1000}</small></label>
|
||||
<span>附件</span>
|
||||
<button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading}>
|
||||
<Icon>{uploading ? 'hourglass_top' : 'upload_file'}</Icon>
|
||||
<span>{uploading ? '上传中…' : '上传文件'}</span>
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" style={{display: 'none'}} onChange={handleFile} />
|
||||
{attachments.length ? <ul className="mailbox-attachment-list">
|
||||
{attachments.map((a, i) => <li key={a.fileId}>{a.name}<i className="material-icons" onClick={() => setAttachments(v => v.filter((_, idx) => idx !== i))}>close</i></li>)}
|
||||
</ul> : null}
|
||||
<button type="button" className="workplace-primary" disabled={submitting || (config == null)} onClick={submit}>{submitting ? '提交中…' : '提交'}</button>
|
||||
<div className="mailbox-anonymous-choice">
|
||||
<label><input type="radio" name="mailbox-anon" checked={anonymous === 'named'} onChange={() => setAnonymous('named')} /><span>实名反馈</span></label>
|
||||
<label><input type="radio" name="mailbox-anon" checked={anonymous === 'anonymous'} onChange={() => setAnonymous('anonymous')}
|
||||
disabled={config && !config.allow_anonymous} /><span>匿名反馈</span></label>
|
||||
</div>
|
||||
</main>
|
||||
{noticeOpen && config && config.notice ? <div className="mailbox-notice-backdrop">
|
||||
<section role="dialog" aria-modal="true" className="mailbox-notice-dialog">
|
||||
<Icon>policy</Icon>
|
||||
<h2>{config.notice.title || '信箱使用与隐私说明'}</h2>
|
||||
<p>{config.notice.content}</p>
|
||||
<button type="button" onClick={() => { setNoticeOpen(false); if (noticeOnly) navigate('executive-mailbox'); }}>我已了解</button>
|
||||
</section>
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function WorkplaceCollaborationView({view, hashParams, myUserId, tinode, onTopicSelected}) {
|
||||
const [toast, setToast] = React.useState('');
|
||||
let content;
|
||||
if (view === 'workbench') content = <Workbench onToast={setToast} />;
|
||||
else if (view === 'organization-directory') content = <OrganizationDirectory myUserId={myUserId} />;
|
||||
else if (view === 'meeting-organizer' || view === 'meeting-attendee') content = <Meetings onToast={setToast} />;
|
||||
else if (view === 'meeting-create') content = <MeetingCreate myUserId={myUserId} tinode={tinode} onToast={setToast} />;
|
||||
else if (view === 'meeting-detail') content = <MeetingDetail hashParams={hashParams} onToast={setToast} />;
|
||||
else if (view === 'daily-report-editor') content = <DailyReportEditor onToast={setToast} />;
|
||||
else if (view === 'monthly-report-editor') content = <MonthlyReportEditor onToast={setToast} />;
|
||||
else if (view === 'daily-report-mine' || view === 'daily-report-team') content = <DailyReportList team={view === 'daily-report-team'} myUserId={myUserId} onToast={setToast} />;
|
||||
else if (view === 'monthly-report-list') content = <MonthlyReportList onToast={setToast} />;
|
||||
else if (view === 'daily-report-detail') content = <DailyReportDetail hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
|
||||
else if (view === 'monthly-report-detail') content = <MonthlyReportDetail hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
|
||||
else content = <ExecutiveMailbox tinode={tinode} noticeOnly={view === 'executive-mailbox-notice'} onToast={setToast} />;
|
||||
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"125.prod.js","mappings":"wLASA,MAAMA,UAA6BC,IAAA,cACjC,WAAAC,CAAYC,GACVC,MAAMD,GAENE,KAAKC,UAAY,GACjB,MAAMC,kBAAEA,GAAsBJ,EAAMK,KACpCC,EAAeC,QAAQC,IACPA,EAAGC,KAAKC,MAAM,KACtBH,QAAQI,IACZT,KAAKC,UAAUS,KAAK,CAClBH,KAAME,EAAKE,OACXC,KAAMN,EAAGM,KACTC,MAAM,EAAAC,EAAAC,IAAUT,EAAGM,MACnBI,KAAMd,EAAkBI,EAAGM,KAAM,CAACK,KAAM,iBAK9CjB,KAAKC,UAAUiB,KAAK,CAACC,EAAGC,IAAMD,EAAEH,KAAKK,cAAcD,EAAEJ,MACvD,CAEA,iBAAAM,GACMtB,KAAKuB,aACPvB,KAAKuB,YAAYC,eAAe,CAACC,MAAO,SAAUC,OAAQ,WAE9D,CAEA,MAAAC,GACE,MAAM1B,EAAY,GACZ2B,EAAW5B,KAAKF,MAAM8B,UAAY,KAYxC,OAXA5B,KAAKC,UAAUI,QAAQ,CAACwB,EAAGC,KACzB,MAAMC,EAASF,EAAEjB,MAAQgB,EAAW,YAAc,GAClD3B,EAAUS,MAAK,EAAAsB,EAAAC,MAAA,MAAIC,UAAWH,EAC5BI,IAAKA,IAAYN,EAAEjB,MAAQgB,IAAW5B,KAAKuB,YAAcY,IACzDC,QAASC,GAAKrC,KAAKF,MAAMwC,SAAST,EAAEjB,KAAMiB,EAAEtB,MAAMgC,SAAA,EAClD,EAAAP,EAAAQ,KAAA,QAAMN,UAAU,eAAcK,SAAEV,EAAEhB,QAClC,EAAAmB,EAAAC,MAAA,QAAMC,UAAU,UAASK,SAAA,CAAC,IAAOV,EAAEb,SACnC,EAAAgB,EAAAC,MAAA,QAAMC,UAAU,YAAWK,SAAA,CAAC,KAAQV,EAAEtB,UALEuB,OAU1C,EAAAE,EAAAQ,KAAA,OAAKN,UAAU,mBAAmBH,MAAO,CAACU,OAAQ,SAASF,UACzD,EAAAP,EAAAQ,KAAA,MAAIN,UAAU,yBAAwBK,SACnCtC,KAIT,EAGFyC,EAAA,SAAe,EAAAC,EAAAC,YAAWjD","sources":["webpack://tinode-webapp/./src/widgets/phone-country-selector.jsx"],"sourcesContent":["// Selector for country dialing code.\n\nimport React from 'react';\nimport { injectIntl } from 'react-intl';\n\nimport * as dcodes from '../dcodes.json';\n\nimport { flagEmoji } from '../lib/strformat';\n\nclass PhoneCountrySelector extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.countries = [];\n const { formatDisplayName } = props.intl;\n dcodes.default.forEach(dc => {\n const parts = dc.dial.split(',');\n parts.forEach(part => {\n this.countries.push({\n dial: part.trim(),\n code: dc.code,\n flag: flagEmoji(dc.code),\n name: formatDisplayName(dc.code, {type: 'region'})\n });\n });\n });\n\n this.countries.sort((a, b) => a.name.localeCompare(b.name));\n }\n\n componentDidMount() {\n if (this.selectedRef) {\n this.selectedRef.scrollIntoView({block: 'center', inline: 'nearest'});\n }\n }\n\n render() {\n const countries = [];\n const selected = this.props.selected || 'US';\n this.countries.forEach((c, idx) => {\n const style = (c.code == selected ? 'selected ' : '');\n countries.push(<li className={style} key={idx}\n ref={ref => {if (c.code == selected) {this.selectedRef = ref}}}\n onClick={_ => this.props.onSubmit(c.code, c.dial)}>\n <span className=\"country-flag\">{c.flag}</span>\n <span className=\"country\"> {c.name}</span>\n <span className=\"dial-code\"> +{c.dial}</span>\n </li>);\n });\n\n return (\n <div className=\"scrollable-panel\" style={{height: '30rem'}}>\n <ul className=\"phone-country-selector\">\n {countries}\n </ul>\n </div>\n );\n }\n}\n\nexport default injectIntl(PhoneCountrySelector);\n"],"names":["PhoneCountrySelector","react__WEBPACK_IMPORTED_MODULE_0___default","constructor","props","super","this","countries","formatDisplayName","intl","_dcodes_json__WEBPACK_IMPORTED_MODULE_2__","forEach","dc","dial","split","part","push","trim","code","flag","_lib_strformat__WEBPACK_IMPORTED_MODULE_3__","lb","name","type","sort","a","b","localeCompare","componentDidMount","selectedRef","scrollIntoView","block","inline","render","selected","c","idx","style","react_jsx_runtime__WEBPACK_IMPORTED_MODULE_4__","jsxs","className","ref","onClick","_","onSubmit","children","jsx","height","__webpack_exports__","react_intl__WEBPACK_IMPORTED_MODULE_1__","injectIntl"],"sourceRoot":""}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"316.prod.js","mappings":"qkNASA,MAAMA,UAA6BC,IAAAA,cACjCC,WAAAA,CAAYC,GACVC,MAAMD,GAENE,KAAKC,UAAY,GACjB,MAAM,kBAAEC,GAAsBJ,EAAMK,KACpCC,EAAeC,QAAQC,IACPA,EAAGC,KAAKC,MAAM,KACtBH,QAAQI,IACZT,KAAKC,UAAUS,KAAK,CAClBH,KAAME,EAAKE,OACXC,KAAMN,EAAGM,KACTC,MAAMC,EAAAA,EAAAA,IAAUR,EAAGM,MACnBG,KAAMb,EAAkBI,EAAGM,KAAM,CAACI,KAAM,iBAK9ChB,KAAKC,UAAUgB,KAAK,CAACC,EAAGC,IAAMD,EAAEH,KAAKK,cAAcD,EAAEJ,MACvD,CAEAM,iBAAAA,GACMrB,KAAKsB,aACPtB,KAAKsB,YAAYC,eAAe,CAACC,MAAO,SAAUC,OAAQ,WAE9D,CAEAC,MAAAA,GACE,MAAMzB,EAAY,GACZ0B,EAAW3B,KAAKF,MAAM6B,UAAY,KAYxC,OAXA3B,KAAKC,UAAUI,QAAQ,CAACuB,EAAGC,KACzB,MAAMC,EAASF,EAAEhB,MAAQe,EAAW,YAAc,GAClD1B,EAAUS,KAAKd,IAAAA,cAAA,MAAImC,UAAWD,EAAOE,IAAKH,EACxCI,IAAKA,IAAYL,EAAEhB,MAAQe,IAAW3B,KAAKsB,YAAcW,IACzDC,QAASC,GAAKnC,KAAKF,MAAMsC,SAASR,EAAEhB,KAAMgB,EAAErB,OAC5CX,IAAAA,cAAA,QAAMmC,UAAU,gBAAgBH,EAAEf,MAClCjB,IAAAA,cAAA,QAAMmC,UAAU,WAAU,IAAOH,EAAEb,MACnCnB,IAAAA,cAAA,QAAMmC,UAAU,aAAY,KAAQH,EAAErB,UAKxCX,IAAAA,cAAA,OAAKmC,UAAU,mBAAmBD,MAAO,CAACO,OAAQ,UAChDzC,IAAAA,cAAA,MAAImC,UAAU,0BACX9B,GAIT,EAGF,WAAeqC,EAAAA,EAAAA,YAAW3C,E","sources":["webpack://tinode-webapp/./src/widgets/phone-country-selector.jsx"],"sourcesContent":["// Selector for country dialing code.\n\nimport React from 'react';\nimport { injectIntl } from 'react-intl';\n\nimport * as dcodes from '../dcodes.json';\n\nimport { flagEmoji } from '../lib/strformat';\n\nclass PhoneCountrySelector extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.countries = [];\n const { formatDisplayName } = props.intl;\n dcodes.default.forEach(dc => {\n const parts = dc.dial.split(',');\n parts.forEach(part => {\n this.countries.push({\n dial: part.trim(),\n code: dc.code,\n flag: flagEmoji(dc.code),\n name: formatDisplayName(dc.code, {type: 'region'})\n });\n });\n });\n\n this.countries.sort((a, b) => a.name.localeCompare(b.name));\n }\n\n componentDidMount() {\n if (this.selectedRef) {\n this.selectedRef.scrollIntoView({block: 'center', inline: 'nearest'});\n }\n }\n\n render() {\n const countries = [];\n const selected = this.props.selected || 'US';\n this.countries.forEach((c, idx) => {\n const style = (c.code == selected ? 'selected ' : '');\n countries.push(<li className={style} key={idx}\n ref={ref => {if (c.code == selected) {this.selectedRef = ref}}}\n onClick={_ => this.props.onSubmit(c.code, c.dial)}>\n <span className=\"country-flag\">{c.flag}</span>\n <span className=\"country\"> {c.name}</span>\n <span className=\"dial-code\"> +{c.dial}</span>\n </li>);\n });\n\n return (\n <div className=\"scrollable-panel\" style={{height: '30rem'}}>\n <ul className=\"phone-country-selector\">\n {countries}\n </ul>\n </div>\n );\n }\n}\n\nexport default injectIntl(PhoneCountrySelector);\n"],"names":["PhoneCountrySelector","React","constructor","props","super","this","countries","formatDisplayName","intl","dcodes","forEach","dc","dial","split","part","push","trim","code","flag","flagEmoji","name","type","sort","a","b","localeCompare","componentDidMount","selectedRef","scrollIntoView","block","inline","render","selected","c","idx","style","className","key","ref","onClick","_","onSubmit","height","injectIntl"],"ignoreList":[],"sourceRoot":""}
|
||||
{"version":3,"file":"316.prod.js","mappings":"wLASA,MAAMA,UAA6BC,IAAA,cACjC,WAAAC,CAAYC,GACVC,MAAMD,GAENE,KAAKC,UAAY,GACjB,MAAMC,kBAAEA,GAAsBJ,EAAMK,KACpCC,EAAeC,QAAQC,IACPA,EAAGC,KAAKC,MAAM,KACtBH,QAAQI,IACZT,KAAKC,UAAUS,KAAK,CAClBH,KAAME,EAAKE,OACXC,KAAMN,EAAGM,KACTC,MAAM,EAAAC,EAAAC,IAAUT,EAAGM,MACnBI,KAAMd,EAAkBI,EAAGM,KAAM,CAACK,KAAM,iBAK9CjB,KAAKC,UAAUiB,KAAK,CAACC,EAAGC,IAAMD,EAAEH,KAAKK,cAAcD,EAAEJ,MACvD,CAEA,iBAAAM,GACMtB,KAAKuB,aACPvB,KAAKuB,YAAYC,eAAe,CAACC,MAAO,SAAUC,OAAQ,WAE9D,CAEA,MAAAC,GACE,MAAM1B,EAAY,GACZ2B,EAAW5B,KAAKF,MAAM8B,UAAY,KAYxC,OAXA5B,KAAKC,UAAUI,QAAQ,CAACwB,EAAGC,KACzB,MAAMC,EAASF,EAAEjB,MAAQgB,EAAW,YAAc,GAClD3B,EAAUS,MAAK,EAAAsB,EAAAC,QAAA,MAAIC,UAAWH,EAC5BI,IAAKA,IAAYN,EAAEjB,MAAQgB,IAAW5B,KAAKuB,YAAcY,IACzDC,QAASC,GAAKrC,KAAKF,MAAMwC,SAAST,EAAEjB,KAAMiB,EAAEtB,MAAMgC,SAAA,EAClD,EAAAP,EAAAC,QAAA,QAAMC,UAAU,eAAcK,SAAEV,EAAEhB,WAAI,OACtC,EAAAmB,EAAAC,QAAA,QAAMC,UAAU,UAASK,SAAA,CAAC,IAAOV,EAAEb,YAAI,OACvC,EAAAgB,EAAAC,QAAA,QAAMC,UAAU,YAAWK,SAAA,CAAC,KAAQV,EAAEtB,YAAI,QALFuB,GAAG,OAU7C,EAAAE,EAAAC,QAAA,OAAKC,UAAU,mBAAmBH,MAAO,CAACS,OAAQ,SAASD,UACzD,EAAAP,EAAAC,QAAA,MAAIC,UAAU,yBAAwBK,SACnCtC,QAAS,YACP,KAGX,EAGFwC,EAAA,SAAe,EAAAC,EAAAC,YAAWhD","sources":["webpack://tinode-webapp/./src/widgets/phone-country-selector.jsx"],"sourcesContent":["// Selector for country dialing code.\n\nimport React from 'react';\nimport { injectIntl } from 'react-intl';\n\nimport * as dcodes from '../dcodes.json';\n\nimport { flagEmoji } from '../lib/strformat';\n\nclass PhoneCountrySelector extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.countries = [];\n const { formatDisplayName } = props.intl;\n dcodes.default.forEach(dc => {\n const parts = dc.dial.split(',');\n parts.forEach(part => {\n this.countries.push({\n dial: part.trim(),\n code: dc.code,\n flag: flagEmoji(dc.code),\n name: formatDisplayName(dc.code, {type: 'region'})\n });\n });\n });\n\n this.countries.sort((a, b) => a.name.localeCompare(b.name));\n }\n\n componentDidMount() {\n if (this.selectedRef) {\n this.selectedRef.scrollIntoView({block: 'center', inline: 'nearest'});\n }\n }\n\n render() {\n const countries = [];\n const selected = this.props.selected || 'US';\n this.countries.forEach((c, idx) => {\n const style = (c.code == selected ? 'selected ' : '');\n countries.push(<li className={style} key={idx}\n ref={ref => {if (c.code == selected) {this.selectedRef = ref}}}\n onClick={_ => this.props.onSubmit(c.code, c.dial)}>\n <span className=\"country-flag\">{c.flag}</span>\n <span className=\"country\"> {c.name}</span>\n <span className=\"dial-code\"> +{c.dial}</span>\n </li>);\n });\n\n return (\n <div className=\"scrollable-panel\" style={{height: '30rem'}}>\n <ul className=\"phone-country-selector\">\n {countries}\n </ul>\n </div>\n );\n }\n}\n\nexport default injectIntl(PhoneCountrySelector);\n"],"names":["PhoneCountrySelector","react__WEBPACK_IMPORTED_MODULE_0___default","constructor","props","super","this","countries","formatDisplayName","intl","_dcodes_json__WEBPACK_IMPORTED_MODULE_2__","forEach","dc","dial","split","part","push","trim","code","flag","_lib_strformat__WEBPACK_IMPORTED_MODULE_3__","lb","name","type","sort","a","b","localeCompare","componentDidMount","selectedRef","scrollIntoView","block","inline","render","selected","c","idx","style","react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__","jsxDEV","className","ref","onClick","_","onSubmit","children","height","__webpack_exports__","react_intl__WEBPACK_IMPORTED_MODULE_1__","injectIntl"],"sourceRoot":""}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[371],{6371:function(e,t,n){n.r(t);var i=n(1594),o=n.n(i),s=n(8181),r=n(1725),a=n(8589);const l=(0,s.defineMessages)({unrecognized_video_format:{id:"unrecognized_video_format",defaultMessage:[{type:0,value:"Format of this video is not recognized"}]}});class d extends o().PureComponent{constructor(e){super(e),this.videoRef=o().createRef(),this.handleSendVideo=this.handleSendVideo.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){this.props.onSendMessage||(e.preventDefault(),"Escape"===e.key&&this.props.onClose())}handleSendVideo(e){this.props.onClose();const t={width:this.videoRef.current.videoWidth,height:this.videoRef.current.videoHeight,duration:1e3*this.videoRef.current.duration|0,mime:this.props.content.mime,name:this.props.content.filename};if(0==t.width||0==t.height)return void this.props.onError(this.props.intl.formatMessage(l.unrecognized_video_format),"err");const n=document.createElement("canvas");n.width=t.width,n.height=t.height;const i=n.getContext("2d");i.drawImage(this.videoRef.current,0,0,n.width,n.height),i.canvas.toBlob(n=>this.props.onSendMessage(e,this.props.content.blob,n,t),"image/jpeg",.75)}render(){if(!this.props.content)return null;const e=this.props.content.width||"-",t=this.props.content.height||"-",n=this.props.onSendMessage?"nodownload":"",i=!this.props.onSendMessage;return o().createElement("div",{id:"image-preview"},o().createElement("div",{id:"preview-caption-panel"},o().createElement("span",null,this.props.content.filename),o().createElement("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()}},o().createElement("i",{className:"material-icons gray"},"close"))),o().createElement("div",{id:"image-preview-container"},o().createElement("video",{className:"image-preview",controls:!0,controlsList:n,disablePictureInPicture:!0,ref:this.videoRef,autoPlay:i,src:this.props.tinode.authorizeURL(this.props.content.url),poster:this.props.content.preview,alt:this.props.content.filename})),this.props.onSendMessage?o().createElement(r.A,{messagePrompt:"add_image_caption",acceptBlank:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendVideo,onError:this.props.onError}):o().createElement("div",{id:"image-preview-footer"},o().createElement("div",null,o().createElement("div",null,o().createElement("b",null,o().createElement(s.FormattedMessage,{id:"label_file_name",defaultMessage:[{type:0,value:"File name:"}]}))),o().createElement("div",null,o().createElement("span",{title:this.props.content.filename},this.props.content.filename))),o().createElement("div",null,o().createElement("div",null,o().createElement("b",null,o().createElement(s.FormattedMessage,{id:"label_content_type",defaultMessage:[{type:0,value:"Content type:"}]}))),o().createElement("div",null,this.props.content.type)),o().createElement("div",null,o().createElement("div",null,o().createElement("b",null,o().createElement(s.FormattedMessage,{id:"label_size",defaultMessage:[{type:0,value:"Size:"}]}))),o().createElement("div",null,e," × ",t," px; ",(0,a.lX)(this.props.content.size)))))}}t.default=(0,s.injectIntl)(d)}}]);
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[371],{6371:function(e,i,n){n.r(i);var o=n(1594),t=n.n(o),s=n(8181),d=n(1725),r=n(8589),a=n(5446);const p=(0,s.defineMessages)({unrecognized_video_format:{id:"unrecognized_video_format",defaultMessage:"Format of this video is not recognized",description:"Error message when uploaded video is invalid"}});class h extends t().PureComponent{constructor(e){super(e),this.videoRef=t().createRef(),this.handleSendVideo=this.handleSendVideo.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){this.props.onSendMessage||(e.preventDefault(),"Escape"===e.key&&this.props.onClose())}handleSendVideo(e){this.props.onClose();const i={width:this.videoRef.current.videoWidth,height:this.videoRef.current.videoHeight,duration:1e3*this.videoRef.current.duration|0,mime:this.props.content.mime,name:this.props.content.filename};if(0==i.width||0==i.height)return void this.props.onError(this.props.intl.formatMessage(p.unrecognized_video_format),"err");const n=document.createElement("canvas");n.width=i.width,n.height=i.height;const o=n.getContext("2d");o.drawImage(this.videoRef.current,0,0,n.width,n.height),o.canvas.toBlob(n=>this.props.onSendMessage(e,this.props.content.blob,n,i),"image/jpeg",.75)}render(){if(!this.props.content)return null;const e=this.props.content.width||"-",i=this.props.content.height||"-",n=this.props.onSendMessage?"nodownload":"",o=!this.props.onSendMessage;return(0,a.jsxDEV)("div",{id:"image-preview",children:[(0,a.jsxDEV)("div",{id:"preview-caption-panel",children:[(0,a.jsxDEV)("span",{children:this.props.content.filename},void 0,!1),(0,a.jsxDEV)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,a.jsxDEV)("i",{className:"material-icons gray",children:"close"},void 0,!1)},void 0,!1)]},void 0,!0),(0,a.jsxDEV)("div",{id:"image-preview-container",children:(0,a.jsxDEV)("video",{className:"image-preview",controls:!0,controlsList:n,disablePictureInPicture:!0,ref:this.videoRef,autoPlay:o,src:this.props.tinode.authorizeURL(this.props.content.url),poster:this.props.content.preview,alt:this.props.content.filename},void 0,!1)},void 0,!1),this.props.onSendMessage?(0,a.jsxDEV)(d.A,{messagePrompt:"add_image_caption",acceptBlank:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendVideo,onError:this.props.onError},void 0,!1):(0,a.jsxDEV)("div",{id:"image-preview-footer",children:[(0,a.jsxDEV)("div",{children:[(0,a.jsxDEV)("div",{children:(0,a.jsxDEV)("b",{children:(0,a.jsxDEV)(s.FormattedMessage,{id:"label_file_name",defaultMessage:"File name:",description:"Label for a file name"},void 0,!1)},void 0,!1)},void 0,!1),(0,a.jsxDEV)("div",{children:(0,a.jsxDEV)("span",{title:this.props.content.filename,children:this.props.content.filename},void 0,!1)},void 0,!1)]},void 0,!0),(0,a.jsxDEV)("div",{children:[(0,a.jsxDEV)("div",{children:(0,a.jsxDEV)("b",{children:(0,a.jsxDEV)(s.FormattedMessage,{id:"label_content_type",defaultMessage:"Content type:",description:"Label for file content type (mime)"},void 0,!1)},void 0,!1)},void 0,!1),(0,a.jsxDEV)("div",{children:this.props.content.type},void 0,!1)]},void 0,!0),(0,a.jsxDEV)("div",{children:[(0,a.jsxDEV)("div",{children:(0,a.jsxDEV)("b",{children:(0,a.jsxDEV)(s.FormattedMessage,{id:"label_size",defaultMessage:"Size:",description:"Label for file size"},void 0,!1)},void 0,!1)},void 0,!1),(0,a.jsxDEV)("div",{children:[e," × ",i," px; ",(0,r.lX)(this.props.content.size)]},void 0,!0)]},void 0,!0)]},void 0,!0)]},void 0,!0)}}i.default=(0,s.injectIntl)(h)}}]);
|
||||
//# sourceMappingURL=371.prod.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[472],{4472:function(e,t,n){n.r(t),n.d(t,{default:function(){return r}});var a=n(1594),l=n.n(a),s=n(8181),o=n(1725),i=n(8589);class r extends l().PureComponent{constructor(e){super(e),this.handleSendDoc=this.handleSendDoc.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){e.preventDefault(),"Escape"===e.key&&this.props.onClose()}handleSendDoc(e){this.props.onClose(),this.props.onSendMessage(this.props.content.file)}render(){return this.props.content?l().createElement("div",{id:"image-preview"},l().createElement("div",{id:"preview-caption-panel"},l().createElement("span",null,this.props.content.name),l().createElement("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()}},l().createElement("i",{className:"material-icons gray"},"close"))),l().createElement("div",{id:"image-preview-container"},l().createElement("div",{className:"doc-card"},l().createElement("i",{className:"material-icons gray"},function(e){const t={default:"insert_drive_file",image:"image",text:"description",video:"theaters"};return t[e]||t[(e||"").split("/")[0]]||t.default}(this.props.content.type)),l().createElement("div",null,l().createElement("b",null,l().createElement(s.FormattedMessage,{id:"label_file_name",defaultMessage:[{type:0,value:"File name:"}]}))," ",(0,i.Tu)(this.props.content.name,24)||"-"),l().createElement("div",null,l().createElement("b",null,l().createElement(s.FormattedMessage,{id:"label_content_type",defaultMessage:[{type:0,value:"Content type:"}]}))," ",this.props.content.type||"application/octet-stream"),l().createElement("div",null,l().createElement("b",null,l().createElement(s.FormattedMessage,{id:"label_size",defaultMessage:[{type:0,value:"Size:"}]}))," ",(0,i.lX)(this.props.content.size)))),l().createElement(o.A,{noInput:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendDoc,onError:this.props.onError})):null}}}}]);
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[472],{4472:function(e,n,i){i.r(n),i.d(n,{default:function(){return p}});var s=i(1594),t=i.n(s),o=i(8181),d=i(1725),r=i(8589),a=i(5446);function l(e){const n={default:"insert_drive_file",image:"image",text:"description",video:"theaters"};return n[e]||n[(e||"").split("/")[0]]||n.default}class p extends t().PureComponent{constructor(e){super(e),this.handleSendDoc=this.handleSendDoc.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){e.preventDefault(),"Escape"===e.key&&this.props.onClose()}handleSendDoc(e){this.props.onClose(),this.props.onSendMessage(this.props.content.file)}render(){return this.props.content?(0,a.jsxDEV)("div",{id:"image-preview",children:[(0,a.jsxDEV)("div",{id:"preview-caption-panel",children:[(0,a.jsxDEV)("span",{children:this.props.content.name},void 0,!1),(0,a.jsxDEV)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,a.jsxDEV)("i",{className:"material-icons gray",children:"close"},void 0,!1)},void 0,!1)]},void 0,!0),(0,a.jsxDEV)("div",{id:"image-preview-container",children:(0,a.jsxDEV)("div",{className:"doc-card",children:[(0,a.jsxDEV)("i",{className:"material-icons gray",children:l(this.props.content.type)},void 0,!1),(0,a.jsxDEV)("div",{children:[(0,a.jsxDEV)("b",{children:(0,a.jsxDEV)(o.FormattedMessage,{id:"label_file_name",defaultMessage:"File name:",description:"Label for a file name"},void 0,!1)},void 0,!1)," ",(0,r.Tu)(this.props.content.name,24)||"-"]},void 0,!0),(0,a.jsxDEV)("div",{children:[(0,a.jsxDEV)("b",{children:(0,a.jsxDEV)(o.FormattedMessage,{id:"label_content_type",defaultMessage:"Content type:",description:"Label for file content type (mime)"},void 0,!1)},void 0,!1)," ",this.props.content.type||"application/octet-stream"]},void 0,!0),(0,a.jsxDEV)("div",{children:[(0,a.jsxDEV)("b",{children:(0,a.jsxDEV)(o.FormattedMessage,{id:"label_size",defaultMessage:"Size:",description:"Label for file size"},void 0,!1)},void 0,!1)," ",(0,r.lX)(this.props.content.size)]},void 0,!0)]},void 0,!0)},void 0,!1),(0,a.jsxDEV)(d.A,{noInput:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendDoc,onError:this.props.onError},void 0,!1)]},void 0,!0):null}}}}]);
|
||||
//# sourceMappingURL=472.prod.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[534],{8534:function(e,t,s){s.r(t),s.d(t,{default:function(){return p}});var i=s(1594),n=s.n(i),o=s(8181),d=s(5372),h=(s(3272),s(7981)),a=s(7256),r=s(7849),l=s(4848);class p extends n().PureComponent{constructor(e){super(e),this.state={width:0,height:0},this.handleSendImage=this.handleSendImage.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){this.props.onSendMessage||(e.preventDefault(),"Escape"===e.key&&this.props.onClose())}assignWidth(e){if(e&&!this.state.width){const t=e.getBoundingClientRect();this.setState({width:0|t.width,height:0|t.height})}}handleSendImage(e){this.props.onClose(),this.props.onSendMessage(e,this.props.content.blob)}render(){if(!this.props.content)return null;const e=(0,h.ae)(this.props.content.width,this.props.content.height,this.state.width,this.state.height,!1),t=e?{width:e.dstWidth+"px",height:e.dstHeight+"px"}:this.props.content.width>this.props.content.height?{width:"100%"}:{height:"100%"};t.maxWidth="100%",t.maxHeight="100%";const s=Math.max((this.state.width/13/1.5|0)-2,12),i=(0,a.Tu)(this.props.content.filename,s)||"-",n=this.props.content.width||"-",p=this.props.content.height||"-",c=(0,r.FH)(this.props.content.url);return(0,l.jsxs)("div",{id:"image-preview",children:[(0,l.jsxs)("div",{id:"preview-caption-panel",children:[this.props.onSendMessage?(0,l.jsx)("span",{children:i}):(0,l.jsxs)("a",{href:c,download:this.props.content.filename,children:[(0,l.jsx)("i",{className:"material-icons",children:"file_download"})," ",(0,l.jsx)(o.FormattedMessage,{id:"download_action",defaultMessage:"download",description:"Call to action [download]"})]}),(0,l.jsx)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,l.jsx)("i",{className:"material-icons gray",children:"close"})})]}),(0,l.jsx)("div",{id:"image-preview-container",ref:e=>this.assignWidth(e),children:(0,l.jsx)("img",{src:this.props.content.url,style:t,className:"image-preview",alt:this.props.content.filename})}),this.props.onSendMessage?(0,l.jsx)(d.A,{messagePrompt:"add_image_caption",acceptBlank:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendImage,onError:this.props.onError}):(0,l.jsxs)("div",{id:"image-preview-footer",children:[(0,l.jsxs)("div",{children:[(0,l.jsx)("div",{children:(0,l.jsx)("b",{children:(0,l.jsx)(o.FormattedMessage,{id:"label_file_name",defaultMessage:"File name:",description:"Label for a file name"})})}),(0,l.jsx)("div",{children:(0,l.jsx)("span",{title:this.props.content.filename,children:i})})]}),(0,l.jsxs)("div",{children:[(0,l.jsx)("div",{children:(0,l.jsx)("b",{children:(0,l.jsx)(o.FormattedMessage,{id:"label_content_type",defaultMessage:"Content type:",description:"Label for file content type (mime)"})})}),(0,l.jsx)("div",{children:this.props.content.type})]}),(0,l.jsxs)("div",{children:[(0,l.jsx)("div",{children:(0,l.jsx)("b",{children:(0,l.jsx)(o.FormattedMessage,{id:"label_size",defaultMessage:"Size:",description:"Label for file size"})})}),(0,l.jsxs)("div",{children:[n," × ",p," px; ",(0,a.lX)(this.props.content.size)]})]})]})]})}}}}]);
|
||||
//# sourceMappingURL=534.prod.js.map
|
||||
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* The buffer module from node.js, for the browser.
|
||||
*
|
||||
* @author Feross Aboukhadijeh <https://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[592],{4592:function(e,i,n){n.r(i);var s=n(1594),t=n.n(s),o=n(8181),r=n(5372),d=n(7256),a=n(4848);const p=(0,o.defineMessages)({unrecognized_video_format:{id:"unrecognized_video_format",defaultMessage:"Format of this video is not recognized",description:"Error message when uploaded video is invalid"}});class h extends t().PureComponent{constructor(e){super(e),this.videoRef=t().createRef(),this.handleSendVideo=this.handleSendVideo.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){this.props.onSendMessage||(e.preventDefault(),"Escape"===e.key&&this.props.onClose())}handleSendVideo(e){this.props.onClose();const i={width:this.videoRef.current.videoWidth,height:this.videoRef.current.videoHeight,duration:1e3*this.videoRef.current.duration|0,mime:this.props.content.mime,name:this.props.content.filename};if(0==i.width||0==i.height)return void this.props.onError(this.props.intl.formatMessage(p.unrecognized_video_format),"err");const n=document.createElement("canvas");n.width=i.width,n.height=i.height;const s=n.getContext("2d");s.drawImage(this.videoRef.current,0,0,n.width,n.height),s.canvas.toBlob(n=>this.props.onSendMessage(e,this.props.content.blob,n,i),"image/jpeg",.75)}render(){if(!this.props.content)return null;const e=this.props.content.width||"-",i=this.props.content.height||"-",n=this.props.onSendMessage?"nodownload":"",s=!this.props.onSendMessage;return(0,a.jsxs)("div",{id:"image-preview",children:[(0,a.jsxs)("div",{id:"preview-caption-panel",children:[(0,a.jsx)("span",{children:this.props.content.filename}),(0,a.jsx)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,a.jsx)("i",{className:"material-icons gray",children:"close"})})]}),(0,a.jsx)("div",{id:"image-preview-container",children:(0,a.jsx)("video",{className:"image-preview",controls:!0,controlsList:n,disablePictureInPicture:!0,ref:this.videoRef,autoPlay:s,src:this.props.tinode.authorizeURL(this.props.content.url),poster:this.props.content.preview,alt:this.props.content.filename})}),this.props.onSendMessage?(0,a.jsx)(r.A,{messagePrompt:"add_image_caption",acceptBlank:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendVideo,onError:this.props.onError}):(0,a.jsxs)("div",{id:"image-preview-footer",children:[(0,a.jsxs)("div",{children:[(0,a.jsx)("div",{children:(0,a.jsx)("b",{children:(0,a.jsx)(o.FormattedMessage,{id:"label_file_name",defaultMessage:"File name:",description:"Label for a file name"})})}),(0,a.jsx)("div",{children:(0,a.jsx)("span",{title:this.props.content.filename,children:this.props.content.filename})})]}),(0,a.jsxs)("div",{children:[(0,a.jsx)("div",{children:(0,a.jsx)("b",{children:(0,a.jsx)(o.FormattedMessage,{id:"label_content_type",defaultMessage:"Content type:",description:"Label for file content type (mime)"})})}),(0,a.jsx)("div",{children:this.props.content.type})]}),(0,a.jsxs)("div",{children:[(0,a.jsx)("div",{children:(0,a.jsx)("b",{children:(0,a.jsx)(o.FormattedMessage,{id:"label_size",defaultMessage:"Size:",description:"Label for file size"})})}),(0,a.jsxs)("div",{children:[e," × ",i," px; ",(0,d.lX)(this.props.content.size)]})]})]})]})}}i.default=(0,o.injectIntl)(h)}}]);
|
||||
//# sourceMappingURL=592.prod.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[623],{1623:function(e,n,s){s.r(n),s.d(n,{default:function(){return p}});var t=s(1594),i=s.n(t),o=s(8181),r=s(5372),a=s(7256),d=s(4848);function l(e){const n={default:"insert_drive_file",image:"image",text:"description",video:"theaters"};return n[e]||n[(e||"").split("/")[0]]||n.default}class p extends i().PureComponent{constructor(e){super(e),this.handleSendDoc=this.handleSendDoc.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){e.preventDefault(),"Escape"===e.key&&this.props.onClose()}handleSendDoc(e){this.props.onClose(),this.props.onSendMessage(this.props.content.file)}render(){return this.props.content?(0,d.jsxs)("div",{id:"image-preview",children:[(0,d.jsxs)("div",{id:"preview-caption-panel",children:[(0,d.jsx)("span",{children:this.props.content.name}),(0,d.jsx)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,d.jsx)("i",{className:"material-icons gray",children:"close"})})]}),(0,d.jsx)("div",{id:"image-preview-container",children:(0,d.jsxs)("div",{className:"doc-card",children:[(0,d.jsx)("i",{className:"material-icons gray",children:l(this.props.content.type)}),(0,d.jsxs)("div",{children:[(0,d.jsx)("b",{children:(0,d.jsx)(o.FormattedMessage,{id:"label_file_name",defaultMessage:"File name:",description:"Label for a file name"})})," ",(0,a.Tu)(this.props.content.name,24)||"-"]}),(0,d.jsxs)("div",{children:[(0,d.jsx)("b",{children:(0,d.jsx)(o.FormattedMessage,{id:"label_content_type",defaultMessage:"Content type:",description:"Label for file content type (mime)"})})," ",this.props.content.type||"application/octet-stream"]}),(0,d.jsxs)("div",{children:[(0,d.jsx)("b",{children:(0,d.jsx)(o.FormattedMessage,{id:"label_size",defaultMessage:"Size:",description:"Label for file size"})})," ",(0,a.lX)(this.props.content.size)]})]})}),(0,d.jsx)(r.A,{noInput:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendDoc,onError:this.props.onError})]}):null}}}}]);
|
||||
//# sourceMappingURL=623.prod.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[686],{3686:function(e,a,t){t.r(a),t.d(a,{default:function(){return c}});var s=t(1594),l=t.n(s),i=t(8181),r=t(184),n=t(3272),d=t(7849),h=t(1781),p=t(4848);class c extends l().PureComponent{constructor(e){super(e);const a=(0,d.MC)(this.props.wallpaper),t=(0,d.t7)(this.props.wallpaper);this.state={tab:a||n.S.type,selectedType:a||n.S.type,wallpaper:t||h[n.S.type][n.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),r.A.navigateTo(r.A.addUrlParam(window.location.hash,"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let a,t,s;if(e)e.preventDefault(),t=this.state.tab,a=e.currentTarget.dataset.id,s="patt"==t?0:this.state.blur;else{if(!this.hasChanged())return;t=n.S.type,a=n.S.index,s=0}const l=h[t][a].name,i="patt"==t?h[t][a].size:0;this.setState({tab:t,wallpaper:l,selectedType:t,blur:s}),this.props.onWallpaperSelected(`../${n.S.path}${l}`,i,this.blurValues[s])}handleBlurChanged(e){e.preventDefault();const a=e.currentTarget.value;this.setState({blur:a}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${n.S.path}${this.state.wallpaper}`,0,this.blurValues[a])}hasChanged(){return this.state.selectedType!=n.S.type||this.state.wallpaper!=h[n.S.type][n.S.index].name||0!=this.state.blur}render(){return(0,p.jsxs)("div",{className:"flex-column",children:[(0,p.jsx)("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":""),children:(0,p.jsxs)("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null),children:[(0,p.jsx)("i",{className:"material-icons",children:"undo"})," ",(0,p.jsx)(i.FormattedMessage,{id:"button_restore",defaultMessage:"Restore default",description:"Reset setting to default"})]})}),(0,p.jsxs)("ul",{className:"tabbar",children:[(0,p.jsx)("li",{className:"patt"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"patt",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:"pattern",description:"Tab title Pattern"})})}),(0,p.jsx)("li",{className:"img"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"img",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_image",defaultMessage:"image",description:"Tab title Image"})})})]}),(0,p.jsx)("div",{id:"settings-form",className:"scrollable-panel",children:"patt"===this.state.tab?(0,p.jsx)("div",{className:"image-grid",children:h.patt.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"",s="dark"==this.props.colorSchema?"inverted":null;return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.name,alt:e.name,className:s,style:{width:`${e.size}px`,height:`${e.size}px`}})},a)})}):(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)("div",{className:"panel-form-column",children:[(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("label",{className:"small",children:(0,p.jsx)(i.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:"Blur:",description:"Label adjusting blur amount"})})}),(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged})})]}),(0,p.jsx)("div",{className:"image-grid",children:h.img.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"";return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}})},a)})})]})})]})}}}}]);
|
||||
//# sourceMappingURL=686.prod.js.map
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[803],{4803:function(e,s,n){n.r(s),n.d(s,{default:function(){return m}});var a=n(1594),t=n.n(a),r=n(8181),i=n(8892),o=n(5372),l=n(5247),d=n(4532),c=n(7981),h=n(4848);const p=(0,r.defineMessages)({unknown_name:{id:"unknown_name",defaultMessage:"Unknown",description:"Name to show when the name is missing"}});class m extends t().PureComponent{constructor(e){super(e),this.state={content:e.content.object},e.content.object||(0,c.QJ)(e.content.file).then(e=>{this.setState({content:e})}),this.handleSendCard=this.handleSendCard.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){e.preventDefault(),"Escape"===e.key&&this.props.onClose()}handleSendCard(){this.props.onClose(),this.props.onSendMessage(this.props.content)}render(){const e=this.state.content,s=d.TheCard.getFn(e),n=d.TheCard.getPhotoUrl(e)||null,a=e?.note,t=d.TheCard.getOrg(e),c=d.TheCard.getComm(e,"email")||[],m=d.TheCard.getComm(e,"tel")||[],u=d.TheCard.getComm(e,"tinode")||[],j=d.TheCard.getComm(e,"http")||[],x=[];return c.forEach((e,s)=>{const n=Array.isArray(e.des)?e.des.join(", "):"";x.push((0,h.jsxs)("div",{className:"group quoted",children:[(0,h.jsx)("tt",{children:e.value}),(0,h.jsxs)("span",{className:"small gray",children:[" ",n?`(${n})`:""]})]},`email-${s}`))}),m.forEach((e,s)=>{let n=e.value;try{const s=(0,i.L)(e.value);n=s?s.formatInternational():e.value}catch(e){}const a=Array.isArray(e.des)?e.des.join(", "):"";x.push((0,h.jsxs)("div",{className:"group quoted",children:[(0,h.jsx)("tt",{children:n}),(0,h.jsxs)("span",{className:"small gray",children:[" ",a?`(${a})`:""]})]},`tel-${s}`))}),u.forEach((e,s)=>{const n=Array.isArray(e.des)?e.des.join(", "):"";x.push((0,h.jsxs)("div",{className:"group quoted",children:[(0,h.jsx)("tt",{children:e.value}),(0,h.jsxs)("span",{className:"small gray",children:[" ",n?`(${n})`:""]})]},`tinode-${s}`))}),j.forEach((e,s)=>{const n=Array.isArray(e.des)?e.des.join(", "):"";x.push((0,h.jsxs)("div",{className:"group quoted",children:[(0,h.jsx)("tt",{children:e.value}),(0,h.jsxs)("span",{className:"small gray",children:[" ",n?`(${n})`:""]})]},`url-${s}`))}),(0,h.jsxs)("div",{id:"image-preview",children:[(0,h.jsxs)("div",{id:"preview-caption-panel",children:[(0,h.jsx)("span",{children:(0,h.jsx)(r.FormattedMessage,{id:"label_contact_card",defaultMessage:"Contact Card",description:"Label for contact card preview"})}),(0,h.jsx)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,h.jsx)("i",{className:"material-icons gray",children:"close"})})]}),(0,h.jsx)("div",{id:"image-preview-container",children:(0,h.jsx)("div",{className:"doc-card",children:(0,h.jsxs)("div",{className:"panel-form-column",children:[(0,h.jsx)("center",{children:(0,h.jsx)("div",{className:"avatar-box",children:(0,h.jsx)(l.A,{authorizeURL:this.props.tinode.authorizeURL,avatar:n||!0,topic:"usrXXX123",title:s})})}),(0,h.jsx)("div",{className:"group",children:(0,h.jsx)("div",{className:"large ellipsized",children:s||(0,h.jsx)(r.FormattedMessage,{...p.unknown_name})})}),t?(0,h.jsx)("div",{className:"group",children:(0,h.jsx)("div",{children:t})}):null,a?(0,h.jsxs)("div",{className:"group",children:[(0,h.jsx)("label",{className:"small",children:(0,h.jsx)(r.FormattedMessage,{id:"label_description",defaultMessage:"Description",description:"Label for description of user or topic"})}),(0,h.jsx)("div",{className:"quoted",children:a})]}):null,x.length>0?(0,h.jsxs)("div",{className:"group",children:[(0,h.jsx)("label",{className:"small",children:(0,h.jsx)(r.FormattedMessage,{id:"label_contacts",defaultMessage:"Contacts",description:"Label for contact information"})}),x]}):null]})})}),(0,h.jsx)(o.A,{noInput:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendCard,onError:this.props.onError})]})}}}}]);
|
||||
//# sourceMappingURL=803.prod.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[827],{3827:function(e,t,a){a.r(t),a.d(t,{default:function(){return p}});var l=a(1594),s=a.n(l),r=a(8181),n=a(7151),i=a(9027),d=a(3540),c=a(1781);class p extends s().PureComponent{constructor(e){super(e);const t=(0,d.MC)(this.props.wallpaper),a=(0,d.t7)(this.props.wallpaper);this.state={tab:t||i.S.type,selectedType:t||i.S.type,wallpaper:a||c[i.S.type][i.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),n.A.navigateTo(n.A.addUrlParam(window.location.hash,"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let t,a,l;if(e)e.preventDefault(),a=this.state.tab,t=e.currentTarget.dataset.id,l="patt"==a?0:this.state.blur;else{if(!this.hasChanged())return;a=i.S.type,t=i.S.index,l=0}const s=c[a][t].name,r="patt"==a?c[a][t].size:0;this.setState({tab:a,wallpaper:s,selectedType:a,blur:l}),this.props.onWallpaperSelected(`../${i.S.path}${s}`,r,this.blurValues[l])}handleBlurChanged(e){e.preventDefault();const t=e.currentTarget.value;this.setState({blur:t}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${i.S.path}${this.state.wallpaper}`,0,this.blurValues[t])}hasChanged(){return this.state.selectedType!=i.S.type||this.state.wallpaper!=c[i.S.type][i.S.index].name||0!=this.state.blur}render(){return s().createElement("div",{className:"flex-column"},s().createElement("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":"")},s().createElement("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null)},s().createElement("i",{className:"material-icons"},"undo")," ",s().createElement(r.FormattedMessage,{id:"button_restore",defaultMessage:[{type:0,value:"Restore default"}]}))),s().createElement("ul",{className:"tabbar"},s().createElement("li",{className:"patt"===this.state.tab?"active":null},s().createElement("a",{href:"#","data-id":"patt",onClick:this.handleTabClick},s().createElement(r.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:[{type:0,value:"pattern"}]}))),s().createElement("li",{className:"img"===this.state.tab?"active":null},s().createElement("a",{href:"#","data-id":"img",onClick:this.handleTabClick},s().createElement(r.FormattedMessage,{id:"tabtitle_image",defaultMessage:[{type:0,value:"image"}]})))),s().createElement("div",{id:"settings-form",className:"scrollable-panel"},"patt"===this.state.tab?s().createElement("div",{className:"image-grid"},c.patt.map((e,t)=>{const a=this.state.wallpaper===e.name?" selected":"",l="dark"==this.props.colorSchema?"inverted":null;return s().createElement("div",{key:t,"data-id":t,className:`image-grid-cell${a}`,onClick:this.handleWallpaperSelected},s().createElement("img",{src:i.S.path+e.name,alt:e.name,className:l,style:{width:`${e.size}px`,height:`${e.size}px`}}))})):s().createElement(s().Fragment,null,s().createElement("div",{className:"panel-form-column"},s().createElement("div",{className:"panel-form-row"},s().createElement("label",{className:"small"},s().createElement(r.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:[{type:0,value:"Blur:"}]}))),s().createElement("div",{className:"panel-form-row"},s().createElement("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged}))),s().createElement("div",{className:"image-grid"},c.img.map((e,t)=>{const a=this.state.wallpaper===e.name?" selected":"";return s().createElement("div",{key:t,"data-id":t,className:`image-grid-cell${a}`,onClick:this.handleWallpaperSelected},s().createElement("img",{src:i.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}}))})))))}}}}]);
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[827],{3827:function(e,a,t){t.r(a),t.d(a,{default:function(){return c}});var s=t(1594),l=t.n(s),i=t(8181),d=t(7151),r=t(9027),n=t(3540),h=t(1781),p=t(5446);class c extends l().PureComponent{constructor(e){super(e);const a=(0,n.MC)(this.props.wallpaper),t=(0,n.t7)(this.props.wallpaper);this.state={tab:a||r.S.type,selectedType:a||r.S.type,wallpaper:t||h[r.S.type][r.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),d.A.navigateTo(d.A.addUrlParam(window.location.hash,"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let a,t,s;if(e)e.preventDefault(),t=this.state.tab,a=e.currentTarget.dataset.id,s="patt"==t?0:this.state.blur;else{if(!this.hasChanged())return;t=r.S.type,a=r.S.index,s=0}const l=h[t][a].name,i="patt"==t?h[t][a].size:0;this.setState({tab:t,wallpaper:l,selectedType:t,blur:s}),this.props.onWallpaperSelected(`../${r.S.path}${l}`,i,this.blurValues[s])}handleBlurChanged(e){e.preventDefault();const a=e.currentTarget.value;this.setState({blur:a}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${r.S.path}${this.state.wallpaper}`,0,this.blurValues[a])}hasChanged(){return this.state.selectedType!=r.S.type||this.state.wallpaper!=h[r.S.type][r.S.index].name||0!=this.state.blur}render(){return(0,p.jsxDEV)("div",{className:"flex-column",children:[(0,p.jsxDEV)("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":""),children:(0,p.jsxDEV)("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null),children:[(0,p.jsxDEV)("i",{className:"material-icons",children:"undo"},void 0,!1)," ",(0,p.jsxDEV)(i.FormattedMessage,{id:"button_restore",defaultMessage:"Restore default",description:"Reset setting to default"},void 0,!1)]},void 0,!0)},void 0,!1),(0,p.jsxDEV)("ul",{className:"tabbar",children:[(0,p.jsxDEV)("li",{className:"patt"===this.state.tab?"active":null,children:(0,p.jsxDEV)("a",{href:"#","data-id":"patt",onClick:this.handleTabClick,children:(0,p.jsxDEV)(i.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:"pattern",description:"Tab title Pattern"},void 0,!1)},void 0,!1)},void 0,!1),(0,p.jsxDEV)("li",{className:"img"===this.state.tab?"active":null,children:(0,p.jsxDEV)("a",{href:"#","data-id":"img",onClick:this.handleTabClick,children:(0,p.jsxDEV)(i.FormattedMessage,{id:"tabtitle_image",defaultMessage:"image",description:"Tab title Image"},void 0,!1)},void 0,!1)},void 0,!1)]},void 0,!0),(0,p.jsxDEV)("div",{id:"settings-form",className:"scrollable-panel",children:"patt"===this.state.tab?(0,p.jsxDEV)("div",{className:"image-grid",children:h.patt.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"",s="dark"==this.props.colorSchema?"inverted":null;return(0,p.jsxDEV)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsxDEV)("img",{src:r.S.path+e.name,alt:e.name,className:s,style:{width:`${e.size}px`,height:`${e.size}px`}},void 0,!1)},a,!1)})},void 0,!1):(0,p.jsxDEV)(p.Fragment,{children:[(0,p.jsxDEV)("div",{className:"panel-form-column",children:[(0,p.jsxDEV)("div",{className:"panel-form-row",children:(0,p.jsxDEV)("label",{className:"small",children:(0,p.jsxDEV)(i.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:"Blur:",description:"Label adjusting blur amount"},void 0,!1)},void 0,!1)},void 0,!1),(0,p.jsxDEV)("div",{className:"panel-form-row",children:(0,p.jsxDEV)("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged},void 0,!1)},void 0,!1)]},void 0,!0),(0,p.jsxDEV)("div",{className:"image-grid",children:h.img.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"";return(0,p.jsxDEV)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsxDEV)("img",{src:r.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}},void 0,!1)},a,!1)})},void 0,!1)]},void 0,!0)},void 0,!1)]},void 0,!0)}}}}]);
|
||||
//# sourceMappingURL=827.prod.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[889],{8889:function(e,t,n){n.r(t),n.d(t,{default:function(){return d}});var s=n(1594),i=n.n(s),a=n(8181),l=n(1725),o=n(9027),r=n(6110),p=n(8589),h=n(3540);class d extends i().PureComponent{constructor(e){super(e),this.state={width:0,height:0},this.handleSendImage=this.handleSendImage.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){this.props.onSendMessage||(e.preventDefault(),"Escape"===e.key&&this.props.onClose())}assignWidth(e){if(e&&!this.state.width){const t=e.getBoundingClientRect();this.setState({width:0|t.width,height:0|t.height})}}handleSendImage(e){this.props.onClose(),this.props.onSendMessage(e,this.props.content.blob)}render(){if(!this.props.content)return null;const e=(0,r.ae)(this.props.content.width,this.props.content.height,this.state.width,this.state.height,!1),t=e?{width:e.dstWidth+"px",height:e.dstHeight+"px"}:this.props.content.width>this.props.content.height?{width:"100%"}:{height:"100%"};t.maxWidth="100%",t.maxHeight="100%";const n=Math.max((this.state.width/o.JS/1.5|0)-2,12),s=(0,p.Tu)(this.props.content.filename,n)||"-",d=this.props.content.width||"-",c=this.props.content.height||"-",m=(0,h.FH)(this.props.content.url);return i().createElement("div",{id:"image-preview"},i().createElement("div",{id:"preview-caption-panel"},this.props.onSendMessage?i().createElement("span",null,s):i().createElement("a",{href:m,download:this.props.content.filename},i().createElement("i",{className:"material-icons"},"file_download")," ",i().createElement(a.FormattedMessage,{id:"download_action",defaultMessage:[{type:0,value:"download"}]})),i().createElement("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()}},i().createElement("i",{className:"material-icons gray"},"close"))),i().createElement("div",{id:"image-preview-container",ref:e=>this.assignWidth(e)},i().createElement("img",{src:this.props.content.url,style:t,className:"image-preview",alt:this.props.content.filename})),this.props.onSendMessage?i().createElement(l.A,{messagePrompt:"add_image_caption",acceptBlank:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendImage,onError:this.props.onError}):i().createElement("div",{id:"image-preview-footer"},i().createElement("div",null,i().createElement("div",null,i().createElement("b",null,i().createElement(a.FormattedMessage,{id:"label_file_name",defaultMessage:[{type:0,value:"File name:"}]}))),i().createElement("div",null,i().createElement("span",{title:this.props.content.filename},s))),i().createElement("div",null,i().createElement("div",null,i().createElement("b",null,i().createElement(a.FormattedMessage,{id:"label_content_type",defaultMessage:[{type:0,value:"Content type:"}]}))),i().createElement("div",null,this.props.content.type)),i().createElement("div",null,i().createElement("div",null,i().createElement("b",null,i().createElement(a.FormattedMessage,{id:"label_size",defaultMessage:[{type:0,value:"Size:"}]}))),i().createElement("div",null,d," × ",c," px; ",(0,p.lX)(this.props.content.size)))))}}}}]);
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[889],{8889:function(e,i,t){t.r(i),t.d(i,{default:function(){return p}});var s=t(1594),n=t.n(s),o=t(8181),d=t(1725),h=(t(9027),t(6110)),a=t(8589),r=t(3540),l=t(5446);class p extends n().PureComponent{constructor(e){super(e),this.state={width:0,height:0},this.handleSendImage=this.handleSendImage.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){this.props.onSendMessage||(e.preventDefault(),"Escape"===e.key&&this.props.onClose())}assignWidth(e){if(e&&!this.state.width){const i=e.getBoundingClientRect();this.setState({width:0|i.width,height:0|i.height})}}handleSendImage(e){this.props.onClose(),this.props.onSendMessage(e,this.props.content.blob)}render(){if(!this.props.content)return null;const e=(0,h.ae)(this.props.content.width,this.props.content.height,this.state.width,this.state.height,!1),i=e?{width:e.dstWidth+"px",height:e.dstHeight+"px"}:this.props.content.width>this.props.content.height?{width:"100%"}:{height:"100%"};i.maxWidth="100%",i.maxHeight="100%";const t=Math.max((this.state.width/13/1.5|0)-2,12),s=(0,a.Tu)(this.props.content.filename,t)||"-",n=this.props.content.width||"-",p=this.props.content.height||"-",c=(0,r.FH)(this.props.content.url);return(0,l.jsxDEV)("div",{id:"image-preview",children:[(0,l.jsxDEV)("div",{id:"preview-caption-panel",children:[this.props.onSendMessage?(0,l.jsxDEV)("span",{children:s},void 0,!1):(0,l.jsxDEV)("a",{href:c,download:this.props.content.filename,children:[(0,l.jsxDEV)("i",{className:"material-icons",children:"file_download"},void 0,!1)," ",(0,l.jsxDEV)(o.FormattedMessage,{id:"download_action",defaultMessage:"download",description:"Call to action [download]"},void 0,!1)]},void 0,!0),(0,l.jsxDEV)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,l.jsxDEV)("i",{className:"material-icons gray",children:"close"},void 0,!1)},void 0,!1)]},void 0,!0),(0,l.jsxDEV)("div",{id:"image-preview-container",ref:e=>this.assignWidth(e),children:(0,l.jsxDEV)("img",{src:this.props.content.url,style:i,className:"image-preview",alt:this.props.content.filename},void 0,!1)},void 0,!1),this.props.onSendMessage?(0,l.jsxDEV)(d.A,{messagePrompt:"add_image_caption",acceptBlank:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendImage,onError:this.props.onError},void 0,!1):(0,l.jsxDEV)("div",{id:"image-preview-footer",children:[(0,l.jsxDEV)("div",{children:[(0,l.jsxDEV)("div",{children:(0,l.jsxDEV)("b",{children:(0,l.jsxDEV)(o.FormattedMessage,{id:"label_file_name",defaultMessage:"File name:",description:"Label for a file name"},void 0,!1)},void 0,!1)},void 0,!1),(0,l.jsxDEV)("div",{children:(0,l.jsxDEV)("span",{title:this.props.content.filename,children:s},void 0,!1)},void 0,!1)]},void 0,!0),(0,l.jsxDEV)("div",{children:[(0,l.jsxDEV)("div",{children:(0,l.jsxDEV)("b",{children:(0,l.jsxDEV)(o.FormattedMessage,{id:"label_content_type",defaultMessage:"Content type:",description:"Label for file content type (mime)"},void 0,!1)},void 0,!1)},void 0,!1),(0,l.jsxDEV)("div",{children:this.props.content.type},void 0,!1)]},void 0,!0),(0,l.jsxDEV)("div",{children:[(0,l.jsxDEV)("div",{children:(0,l.jsxDEV)("b",{children:(0,l.jsxDEV)(o.FormattedMessage,{id:"label_size",defaultMessage:"Size:",description:"Label for file size"},void 0,!1)},void 0,!1)},void 0,!1),(0,l.jsxDEV)("div",{children:[n," × ",p," px; ",(0,a.lX)(this.props.content.size)]},void 0,!0)]},void 0,!0)]},void 0,!0)]},void 0,!0)}}}}]);
|
||||
//# sourceMappingURL=889.prod.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[942],{8942:function(e,t,a){a.r(t),a.d(t,{default:function(){return p}});var n=a(1594),l=a.n(n),s=a(8181),r=a(8892),o=a(1725),c=a(6904),d=a(4532),i=a(6110);const m=(0,s.defineMessages)({unknown_name:{id:"unknown_name",defaultMessage:[{type:0,value:"Unknown"}]}});class p extends l().PureComponent{constructor(e){super(e),this.state={content:e.content.object},e.content.object||(0,i.QJ)(e.content.file).then(e=>{this.setState({content:e})}),this.handleSendCard=this.handleSendCard.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){e.preventDefault(),"Escape"===e.key&&this.props.onClose()}handleSendCard(){this.props.onClose(),this.props.onSendMessage(this.props.content)}render(){const e=this.state.content,t=d.TheCard.getFn(e),a=d.TheCard.getPhotoUrl(e)||null,n=e?.note,i=d.TheCard.getOrg(e),p=d.TheCard.getComm(e,"email")||[],u=d.TheCard.getComm(e,"tel")||[],h=d.TheCard.getComm(e,"tinode")||[],E=d.TheCard.getComm(e,"http")||[],v=[];return p.forEach((e,t)=>{const a=Array.isArray(e.des)?e.des.join(", "):"";v.push(l().createElement("div",{className:"group quoted",key:`email-${t}`},l().createElement("tt",null,e.value),l().createElement("span",{className:"small gray"}," ",a?`(${a})`:"")))}),u.forEach((e,t)=>{let a=e.value;try{const t=(0,r.L)(e.value);a=t?t.formatInternational():e.value}catch(e){}const n=Array.isArray(e.des)?e.des.join(", "):"";v.push(l().createElement("div",{className:"group quoted",key:`tel-${t}`},l().createElement("tt",null,a),l().createElement("span",{className:"small gray"}," ",n?`(${n})`:"")))}),h.forEach((e,t)=>{const a=Array.isArray(e.des)?e.des.join(", "):"";v.push(l().createElement("div",{className:"group quoted",key:`tinode-${t}`},l().createElement("tt",null,e.value),l().createElement("span",{className:"small gray"}," ",a?`(${a})`:"")))}),E.forEach((e,t)=>{const a=Array.isArray(e.des)?e.des.join(", "):"";v.push(l().createElement("div",{className:"group quoted",key:`url-${t}`},l().createElement("tt",null,e.value),l().createElement("span",{className:"small gray"}," ",a?`(${a})`:"")))}),l().createElement("div",{id:"image-preview"},l().createElement("div",{id:"preview-caption-panel"},l().createElement("span",null,l().createElement(s.FormattedMessage,{id:"label_contact_card",defaultMessage:[{type:0,value:"Contact Card"}]})),l().createElement("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()}},l().createElement("i",{className:"material-icons gray"},"close"))),l().createElement("div",{id:"image-preview-container"},l().createElement("div",{className:"doc-card"},l().createElement("div",{className:"panel-form-column"},l().createElement("center",null,l().createElement("div",{className:"avatar-box"},l().createElement(c.A,{authorizeURL:this.props.tinode.authorizeURL,avatar:a||!0,topic:"usrXXX123",title:t}))),l().createElement("div",{className:"group"},l().createElement("div",{className:"large ellipsized"},t||l().createElement(s.FormattedMessage,m.unknown_name))),i?l().createElement("div",{className:"group"},l().createElement("div",null,i)):null,n?l().createElement("div",{className:"group"},l().createElement("label",{className:"small"},l().createElement(s.FormattedMessage,{id:"label_description",defaultMessage:[{type:0,value:"Description"}]})),l().createElement("div",{className:"quoted"},n)):null,v.length>0?l().createElement("div",{className:"group"},l().createElement("label",{className:"small"},l().createElement(s.FormattedMessage,{id:"label_contacts",defaultMessage:[{type:0,value:"Contacts"}]})),v):null))),l().createElement(o.A,{noInput:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendCard,onError:this.props.onError}))}}}}]);
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[942],{8942:function(e,s,n){n.r(s),n.d(s,{default:function(){return v}});var a=n(1594),i=n.n(a),o=n(8181),d=n(8892),t=n(1725),r=n(6904),l=n(4532),c=n(6110),h=n(5446);const p=(0,o.defineMessages)({unknown_name:{id:"unknown_name",defaultMessage:"Unknown",description:"Name to show when the name is missing"}});class v extends i().PureComponent{constructor(e){super(e),this.state={content:e.content.object},e.content.object||(0,c.QJ)(e.content.file).then(e=>{this.setState({content:e})}),this.handleSendCard=this.handleSendCard.bind(this),this.handleKeyDown=this.handleKeyDown.bind(this)}componentDidMount(){document.addEventListener("keydown",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener("keydown",this.handleKeyDown)}handleKeyDown(e){e.preventDefault(),"Escape"===e.key&&this.props.onClose()}handleSendCard(){this.props.onClose(),this.props.onSendMessage(this.props.content)}render(){const e=this.state.content,s=l.TheCard.getFn(e),n=l.TheCard.getPhotoUrl(e)||null,a=e?.note,i=l.TheCard.getOrg(e),c=l.TheCard.getComm(e,"email")||[],v=l.TheCard.getComm(e,"tel")||[],m=l.TheCard.getComm(e,"tinode")||[],u=l.TheCard.getComm(e,"http")||[],D=[];return c.forEach((e,s)=>{const n=Array.isArray(e.des)?e.des.join(", "):"";D.push((0,h.jsxDEV)("div",{className:"group quoted",children:[(0,h.jsxDEV)("tt",{children:e.value},void 0,!1),(0,h.jsxDEV)("span",{className:"small gray",children:[" ",n?`(${n})`:""]},void 0,!0)]},`email-${s}`,!0))}),v.forEach((e,s)=>{let n=e.value;try{const s=(0,d.L)(e.value);n=s?s.formatInternational():e.value}catch(e){}const a=Array.isArray(e.des)?e.des.join(", "):"";D.push((0,h.jsxDEV)("div",{className:"group quoted",children:[(0,h.jsxDEV)("tt",{children:n},void 0,!1),(0,h.jsxDEV)("span",{className:"small gray",children:[" ",a?`(${a})`:""]},void 0,!0)]},`tel-${s}`,!0))}),m.forEach((e,s)=>{const n=Array.isArray(e.des)?e.des.join(", "):"";D.push((0,h.jsxDEV)("div",{className:"group quoted",children:[(0,h.jsxDEV)("tt",{children:e.value},void 0,!1),(0,h.jsxDEV)("span",{className:"small gray",children:[" ",n?`(${n})`:""]},void 0,!0)]},`tinode-${s}`,!0))}),u.forEach((e,s)=>{const n=Array.isArray(e.des)?e.des.join(", "):"";D.push((0,h.jsxDEV)("div",{className:"group quoted",children:[(0,h.jsxDEV)("tt",{children:e.value},void 0,!1),(0,h.jsxDEV)("span",{className:"small gray",children:[" ",n?`(${n})`:""]},void 0,!0)]},`url-${s}`,!0))}),(0,h.jsxDEV)("div",{id:"image-preview",children:[(0,h.jsxDEV)("div",{id:"preview-caption-panel",children:[(0,h.jsxDEV)("span",{children:(0,h.jsxDEV)(o.FormattedMessage,{id:"label_contact_card",defaultMessage:"Contact Card",description:"Label for contact card preview"},void 0,!1)},void 0,!1),(0,h.jsxDEV)("a",{href:"#",onClick:e=>{e.preventDefault(),this.props.onClose()},children:(0,h.jsxDEV)("i",{className:"material-icons gray",children:"close"},void 0,!1)},void 0,!1)]},void 0,!0),(0,h.jsxDEV)("div",{id:"image-preview-container",children:(0,h.jsxDEV)("div",{className:"doc-card",children:(0,h.jsxDEV)("div",{className:"panel-form-column",children:[(0,h.jsxDEV)("center",{children:(0,h.jsxDEV)("div",{className:"avatar-box",children:(0,h.jsxDEV)(r.A,{authorizeURL:this.props.tinode.authorizeURL,avatar:n||!0,topic:"usrXXX123",title:s},void 0,!1)},void 0,!1)},void 0,!1),(0,h.jsxDEV)("div",{className:"group",children:(0,h.jsxDEV)("div",{className:"large ellipsized",children:s||(0,h.jsxDEV)(o.FormattedMessage,{...p.unknown_name},void 0,!1)},void 0,!1)},void 0,!1),i?(0,h.jsxDEV)("div",{className:"group",children:(0,h.jsxDEV)("div",{children:i},void 0,!1)},void 0,!1):null,a?(0,h.jsxDEV)("div",{className:"group",children:[(0,h.jsxDEV)("label",{className:"small",children:(0,h.jsxDEV)(o.FormattedMessage,{id:"label_description",defaultMessage:"Description",description:"Label for description of user or topic"},void 0,!1)},void 0,!1),(0,h.jsxDEV)("div",{className:"quoted",children:a},void 0,!1)]},void 0,!0):null,D.length>0?(0,h.jsxDEV)("div",{className:"group",children:[(0,h.jsxDEV)("label",{className:"small",children:(0,h.jsxDEV)(o.FormattedMessage,{id:"label_contacts",defaultMessage:"Contacts",description:"Label for contact information"},void 0,!1)},void 0,!1),D]},void 0,!0):null]},void 0,!0)},void 0,!1)},void 0,!1),(0,h.jsxDEV)(t.A,{noInput:!0,tinode:this.props.tinode,reply:this.props.reply,onCancelReply:this.props.onCancelReply,onSendMessage:this.handleSendCard,onError:this.props.onError},void 0,!1)]},void 0,!0)}}}}]);
|
||||
//# sourceMappingURL=942.prod.js.map
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user