第八轮最小粒度测试:修复CSS选择器/JSX结构不匹配的一整类bug,清理Tinode品牌残留

第八轮改用"读CSS选择器+核对JSX结构"的方式复测全部11章,命中了7轮纯接口测试从未发现的一类
问题——选择器写对了但目标元素的实际DOM结构/className对不上,样式静默失效:

- 语音通话最小化胶囊按钮尺寸被同特异度规则覆盖;移动端悬浮通话窗口left/right/width三值同时
  生效导致过度约束;来电全屏页认证徽章/姓名标题缺少专属样式覆盖
- 我的收藏-语音卡片三列grid对应实际只有1个子元素,播放器被压缩到卡片左侧一角;链接/记录头部
  完全没有基础样式,退化成浏览器默认外观
- 多选模式下"收到的"消息气泡被flex auto-margin一起拖到最右侧;.avatar-box/#send-message-panel
  两处逗号断句错误导致后半句选择器丢失祖先限定
- 按住说话悬浮反馈渲染成内联胶囊,而参考图要求的居中悬浮卡片样式其实已经存在(挂在了另一个非
  生产demo组件上);批量转发确认框沿用了80%视口高度;audio-player.jsx的playback类点在canvas
  上而非包裹层,波形和计时器可能纵向堆叠
- "消息已撤回"占位符误拼了日期分隔条的class而非专属的deleted

品牌清理:浏览器标签页标题、未读数标题更新均硬编码"Tinode";LINK_CONTACT_US等三个链接常量
指向开源Tinode项目自己的域名(联系我们改为跟用户条款/隐私协议一致的toast,不再发起外部mailto)。

以及:空会话丢失.chat-box样式类、青少年模式PIN定位、若干收藏/文件类型展示细节。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-05 17:09:42 +08:00
co-authored by Claude Sonnet 5
parent 5e173c55e8
commit d759f67b11
24 changed files with 375 additions and 250 deletions
+154 -85
View File
@@ -1258,7 +1258,12 @@
.report-author {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) auto;
/* Same dead-avatar-column issue .report-cards article > header had (see its comment) and was
fixed for -- DailyReportDetail/MonthlyReportDetail (the only two renderers of this class)
never render an <img> here either, just the name+badge span and the status <em>. The unused
leading 3rem column squeezed the name into a column narrow enough to wrap "员工" onto its own
line, while the status badge landed in what should've been the name's share of the row. */
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
}
@@ -2117,6 +2122,8 @@ input[type="email"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="date"],
input[type="time"],
textarea {
height: 2.5rem;
border-bottom: 1px solid var(--clr-input-border);
@@ -2128,6 +2135,8 @@ input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
input[type="date"],
input[type="time"],
textarea {
width: 100%;
}
@@ -2137,7 +2146,9 @@ input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="search"]:focus,
input[type="tel"]:focus {
input[type="tel"]:focus,
input[type="date"]:focus,
input[type="time"]:focus {
border-bottom: 1px solid var(--clr-input-border-focus);
box-shadow: 0 1px 0 var(--clr-input-shadow-focus);
}
@@ -3114,7 +3125,7 @@ img.channel {
}
/* Online-offline indicator positioned inside avatar-box */
.avatar-box .online, .offline {
.avatar-box .online, .avatar-box .offline {
position: absolute;
width: 0.75rem;
min-width: 0.75rem;
@@ -3582,9 +3593,15 @@ img.channel {
.select-checkbox.blue {
color: var(--clr-primary-accent);
}
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row regardless of
whether the message itself is left- or right-aligned (docs/images/多选-选中对话框.png). */
.chat-box li .select-checkbox {
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row for sent (.right)
messages, where it would otherwise cluster next to the bubble at the row's right edge along with
everything else justify-content:flex-end pulls there. Scoped to .right only -- applying it
unconditionally (as this rule originally did) claims 100% of the row's remaining space for the
auto margin regardless of alignment, which on a received (.left) message drags the avatar+bubble
that follow the checkbox all the way to the row's right edge instead of leaving them where
justify-content's default flex-start already puts them: immediately after the checkbox, at the
row's left edge (docs/images/多选-选中对话框.png). */
.chat-box li.right .select-checkbox {
margin-right: auto;
}
@@ -3637,12 +3654,29 @@ img.channel {
.batch-forward-confirm {
max-width: 24rem;
}
/* Also carries .forward-dialog for its base sheet chrome, but that class's own height:80% (right
for forward-dialog.jsx's long contact list) leaves this content-sized confirm sheet mostly empty
space below its buttons. Same (single-class) specificity as .forward-dialog itself, so a bare
.batch-forward-confirm{height:auto} would depend on which rule happens to come later in this
file to win -- the compound selector here beats it unconditionally instead. */
.forward-dialog.batch-forward-confirm {
height: auto;
}
.batch-forward-target {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
}
/* .contact-box > li (2969 above) is more specific than the bare .batch-forward-target class above,
so its padding and its avatar-reserving divider (the gradient's gap starts at 3.5rem, assuming an
avatar occupies that space) win by default -- this row has no avatar, so the divider ends up
looking like it starts partway across the row. Reasserted here at matching specificity with a
full-width divider instead. */
.contact-box > li.batch-forward-target {
padding: 0.6rem 0;
background: linear-gradient(to right, var(--clr-background-extra-light-gray), var(--clr-background-extra-light-gray)) 0 100% / 100% 1px no-repeat, var(--clr-bright);
}
/* 从只读 div 改成可输入的留言框后,要显式清掉 input 的原生边框/宽度,否则会缩成一个小方框。 */
.batch-forward-comment {
box-sizing: border-box;
@@ -3900,7 +3934,18 @@ img.channel {
font-size: 1.6rem;
}
#send-message-panel .material-icons.secondary, .material-icons.disabled {
#send-message-panel .material-icons.secondary {
color: var(--clr-text-tertiary)!important;
}
/* Split out from the rule above -- it was originally a second, unscoped clause on it (a copy-paste
slip, matching the same .avatar-box .online/.offline mistake elsewhere in this file), which in
practice is the only rule greying out letter-tile.jsx's generic (no-letter) deleted-topic icon
(`<i class="material-icons {color} disabled">`, no wrapper class of its own to hang a scoped rule
off of) -- letter-tile.jsx's *other* deleted-avatar case (.lettertile.disabled) has its own real
rule and was never affected. Kept as an explicit, separately-scoped rule instead of silently
dropping a behavior nothing else provides. */
i.material-icons.disabled {
color: var(--clr-text-tertiary)!important;
}
@@ -4221,7 +4266,7 @@ button.full-screen {
min-width: unset;
}
.minimized button.full-screen {
.minimized:not(.audio-minimized) button.full-screen {
top: 1rem;
left: 1rem;
width: 2rem;
@@ -4236,7 +4281,7 @@ button.full-screen .material-icons {
font-size: x-large;
}
.minimized button.full-screen .material-icons {
.minimized:not(.audio-minimized) button.full-screen .material-icons {
font-size: large;
}
@@ -4362,7 +4407,7 @@ video {
margin-top: 0.5rem;
}
.caller-card .trusted-badge {
.caller-card .trusted-badge, .call-peer-card h1 .trusted-badge {
display: inline-block;
}
@@ -5497,29 +5542,12 @@ ul.tabbar li.active > a {
margin-right: 0;
}
.audio-hold {
flex: 1;
justify-content: center;
gap: 0.6rem;
}
.audio-hold canvas {
/* .compose-record-overlay (8588 below) replaced this holdMode presentation -- see
audio-recorder.jsx's holdMode render for why. */
.compose-record-overlay canvas {
width: 6rem;
margin-right: 0;
}
.audio-hold-hint {
color: var(--clr-text-tertiary);
font-size: 0.85rem;
}
.audio-hold.cancelling {
background-color: var(--clr-danger-text, #d93025);
}
.audio-hold.cancelling .duration,
.audio-hold.cancelling .audio-hold-hint {
color: #fff;
height: 1.8rem;
margin-top: 0.4rem;
}
.audio .duration {
@@ -5550,7 +5578,11 @@ ul.tabbar li.active > a {
display: block;
}
.audio-player div.playback {
/* Targets the wrapper div around canvas.playback + .timer (audio-player.jsx's non-short render) --
was accidentally written as div.playback, but "playback" is the canvas's own class, not this
wrapper's (which had no class at all), so this never matched anything; canvas+timer fell back to
block-level stacking (timer on its own line below the waveform) instead of sitting side by side. */
.audio-player .playback-row {
display: flex;
align-items: center;
justify-content: center;
@@ -6786,6 +6818,17 @@ div.toast.show {
opacity: 0;
}
/* .auth-pin-input's shared top:11rem was measured against .auth-pin-content's layout (6rem
top padding + an <h1> pushing .auth-pin-boxes further down) -- .youth-password-gate has no
title and only 3rem of top padding, so .auth-pin-boxes sits ~9.25rem higher here and the
invisible real input ended up entirely below the visible boxes (confirmed live: boxes at
86.7-140px, input at 210-243px, a real click on the boxes hit nothing and never refocused
the input). Re-anchored to this container's own shorter layout instead of reusing the other
page's absolute value. */
.youth-password-gate .auth-pin-input {
top: 1.75rem;
}
.auth-pin-content .auth-primary {
width: min(100%, 24rem);
margin-top: 3rem;
@@ -7056,6 +7099,7 @@ div.toast.show {
margin-top: 0.4rem;
overflow: hidden;
font-size: 0.78rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -7552,22 +7596,6 @@ div.toast.show {
line-height: 1.55;
}
.system-notification-card p button {
display: inline;
width: auto;
min-width: 0;
height: auto;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
box-shadow: none;
color: #1683f8;
background: transparent;
font: inherit;
text-transform: none;
}
.system-notification-card dl {
margin: 0;
}
@@ -9162,7 +9190,8 @@ div.toast.show {
.group-list button > span:nth-child(2),
.group-request-list article > div,
.group-admin-page article > span:nth-child(2),
.group-muted-list article > span:nth-child(2) {
.group-muted-list article > span:nth-child(2),
.group-member-picker > button > span:nth-child(2) {
display: flex;
min-width: 0;
flex-direction: column;
@@ -9353,15 +9382,6 @@ div.toast.show {
text-transform: none;
}
.people-search-result em {
padding: 0.4rem 0.8rem;
border-radius: 0.3rem;
color: #fff;
background: #398afa;
font-size: 0.78rem;
font-style: normal;
}
.people-qr-page main,
.group-qr-page main {
display: flex;
@@ -9684,8 +9704,12 @@ div.toast.show {
.people-settings section small,
.group-settings section small {
min-width: 0;
margin-left: auto;
overflow: hidden;
color: #949ba4;
text-overflow: ellipsis;
white-space: nowrap;
}
.people-switch,
@@ -10212,20 +10236,6 @@ div.toast.show {
line-height: 1.8;
}
.group-announcement-detail footer {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eceef1;
}
.group-announcement-detail footer > span {
display: flex;
flex-direction: column;
}
.group-announcement-detail > small {
display: block;
margin-top: 1rem;
@@ -10309,12 +10319,15 @@ div.toast.show {
flex: 0 0 3.6rem;
}
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开 */
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开。宽高本该跟上面那条规则
一致(3.6rem)——之前这里写成了3rem,两条规则特异性相同、这条排在后面,字面覆盖了 width/
height,但没盖到上面那条的 flex:0 0 3.6rem(这条完全没提 flex),容器又是 flex-direction:
column,主轴尺寸吃 flex-basis 不吃 height,实际渲成了 3rem 宽、3.6rem 高的长方形。 */
.group-member-tile-action {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
width: 3.6rem;
height: 3.6rem;
border-radius: 0.45rem;
color: #98a0aa;
background: #f0f2f5;
@@ -10569,11 +10582,6 @@ div.toast.show {
text-transform: none;
}
.group-member-picker > button > span:nth-child(2) {
display: flex;
flex-direction: column;
}
.group-check {
display: grid;
place-items: center;
@@ -10866,7 +10874,7 @@ div.toast.show {
.library-favorite-content > footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 2rem;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
@@ -10959,6 +10967,32 @@ div.toast.show {
font-style: normal;
}
.library-link-text {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
min-width: 0;
height: auto;
min-height: 2.8rem;
margin: 0;
padding: 0.6rem 0.8rem;
border: 1px solid #eceef1;
border-radius: 0.4rem;
box-shadow: none;
color: var(--clr-primary-accent);
background: #fafbfc;
text-align: left;
text-transform: none;
line-height: 1.3;
}
.library-link-text span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Shared by VoiceCard/LinkText/MediaThumb's "removed" state (content-library-view.jsx) -- same
opacity + red label language as .library-file-card.removed/em above, but these three don't share
its grid layout (voice/media assume an icon+waveform or an image backdrop that isn't there once
@@ -10982,8 +11016,7 @@ div.toast.show {
}
.library-voice-card {
display: grid;
grid-template-columns: 2rem minmax(4rem, 12rem) auto;
display: flex;
align-items: center;
gap: 0.6rem;
width: min(100%, 20rem);
@@ -11501,8 +11534,39 @@ div.toast.show {
background: #fff;
}
.library-record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
min-width: 0;
height: auto;
margin: 0 0 0.4rem;
padding: 0;
border: 0;
box-shadow: none;
color: inherit;
background: transparent;
text-align: left;
text-transform: none;
}
.library-record-header strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.library-record-header small {
flex: none;
color: #9299a2;
font-size: 0.74rem;
}
.library-record-files .library-file-card,
.library-record-links .library-link-card {
.library-record-links .library-link-text {
margin-bottom: 0.7rem;
}
@@ -11851,6 +11915,10 @@ div.toast.show {
margin: 1.3rem 0 0.45rem;
color: #fff;
font-size: 1.55rem;
max-width: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.call-peer-card p {
@@ -12606,6 +12674,7 @@ div.toast.show {
#video-container.minimized {
flex-direction: row;
left: 0;
right: auto;
top: var(--caption-panel-height);
width: 100%;
height: 11rem;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -2,7 +2,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tinode</title>
<title>MeetingIM</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web, version for developers" />
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>Tinode</title>
<title>MeetingIM</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web" />
+7 -8
View File
@@ -119,14 +119,13 @@ export const MIN_DURATION = 2_000;
// Maximum duration of an audio recording in milliseconds (10 min).
export const MAX_DURATION = 600_000;
// Link for "Contact Us".
export const LINK_CONTACT_US = 'mailto:support@tinode.co';
// Link to Privacy Policy.
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
// Link to Terms of Service.
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
// No real destination exists for these (this is a private multi-tenant product, not the open-source
// Tinode project) -- null rather than the upstream project's own contact/legal pages, which acc-support-view.jsx
// renders as inert text instead of a link (same treatment auth-onboarding-view.jsx already gives its
// own placeholder 用户协议/隐私政策 links).
export const LINK_CONTACT_US = null;
export const LINK_PRIVACY_POLICY = null;
export const LINK_TERMS_OF_SERVICE = null;
// When user starts a P2P topic, automatic subscription does not
// happen until the first message. Setting this option to true enables
+2 -1
View File
@@ -268,7 +268,8 @@ const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除'
'gone': '内容不存在或已被删除',
'not found': '内容不存在或已被删除'
};
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
+2 -2
View File
@@ -1,7 +1,7 @@
// Odds and ends
import { Tinode } from 'tinode-sdk';
import { WALLPAPER_DEFAULTS } from '../config.js';
import { WALLPAPER_DEFAULTS, BASE_APP_NAME } 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
@@ -36,7 +36,7 @@ export function updateFavicon(count) {
}
head.appendChild(newIcon);
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
document.title = (count > 0 ? '('+count+') ' : '') + BASE_APP_NAME;
}
// Create theCard which represents user's or topic's "public" info.
+18 -18
View File
@@ -2,27 +2,31 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Tinode } from 'tinode-sdk';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
// Renders as a real link when a destination is configured, or as inert (non-navigating) text
// otherwise -- same treatment auth-onboarding-view.jsx already gives its own placeholder
// 用户协议/隐私政策 links when there's no real page to send the user to.
function FlatLink({href, icon, children}) {
return href ?
<a href={href} className="flat-button" target="_blank"><i className="material-icons">{icon}</i> &nbsp;{children}</a> :
<span className="flat-button"><i className="material-icons">{icon}</i> &nbsp;{children}</span>;
}
export default class AccSupportView extends React.PureComponent {
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
<i className="material-icons">email</i> &nbsp;<FormattedMessage id="link_contact_us"
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</a>
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
<i className="material-icons">description</i> &nbsp;<FormattedMessage id="link_terms_of_service"
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</a>
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
<i className="material-icons">policy</i> &nbsp;<FormattedMessage id="link_privacy_policy"
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</a>
<FlatLink href={LINK_CONTACT_US} icon="email">
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</FlatLink>
<FlatLink href={LINK_TERMS_OF_SERVICE} icon="description">
<FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</FlatLink>
<FlatLink href={LINK_PRIVACY_POLICY} icon="policy">
<FormattedMessage id="link_privacy_policy" defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</FlatLink>
</div>
<div className="hr" />
<div className="panel-form-column">
@@ -31,10 +35,6 @@ export default class AccSupportView extends React.PureComponent {
description="Label for a client version" /></label>
{APP_NAME}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
{Tinode.getLibrary()}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
description="Label for a server version" /></label>
+11 -3
View File
@@ -232,9 +232,14 @@ function EnterpriseRegister() {
return <div className="auth-page">
<Header title="企业注册" />
<main className="auth-content auth-content-narrow">
{/* .active marks a step (and the connector after it) as reached, not "this is the current
step" -- steps stay lit once passed. There's no separate server-tracked "确定信息" state
(submitting the form *is* confirming it), so step 2 only ever gets skipped straight from
1 to 3; without submitted also keeping step 1 lit, submitting made it look like step 1 had
been undone instead of completed. */}
<ol className="auth-steps">
<li className={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li>
<li><span>2</span><b>确定信息</b></li>
<li className="active"><span>1</span><b>填写企业信息</b></li>
<li className={submitted ? 'active' : ''}><span>2</span><b>确定信息</b></li>
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
</ol>
<form className="auth-form" onSubmit={submit}>
@@ -462,8 +467,11 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
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} />
{/* 邀请码大小写敏感(server/store/... 的唯一索引是 ascii_bin 排序),不能像信用代码那样
.toUpperCase() 统一大小写;autoCapitalize="off" 只是防止移动端输入法把首字母偷偷转大写,
没有它,一个真实有效、首字母是小写的邀请码会被静默改错且用户毫无察觉。 */}
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
placeholder="请输入邀请码(非必填)" error={errors.invite} />
placeholder="请输入邀请码(非必填)" error={errors.invite} autoCapitalize="off" autoCorrect="off" />
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'}
</button>
+4 -2
View File
@@ -121,7 +121,7 @@ function AddressBook({myUserId, onTopicSelected}) {
fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor))
.then(list => setPendingCount(list.length)).catch(() => {});
}, []);
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.includes(query));
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.toLowerCase().includes(query.toLowerCase()));
const groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name);
return {...result, [key]: (result[key] || []).concat(item)};
@@ -548,7 +548,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
}} /></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> :
// Backend column is VARCHAR(64) and actually enforces it (65 chars -> 400) -- confirmed
// live, this used to cap input at less than a third of what the server accepts.
<div className="people-remark-edit"><input value={remarkDraft} maxLength="64" 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; }
+14 -2
View File
@@ -109,15 +109,27 @@ function plainTextOf(content, 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']};
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : '';
}
// Ch.6's getGroupFiles() returns a server-classified file_type field; favorites/history items have
// no equivalent (they're derived from a Drafty attachment, not a registered file record), so it's
// approximated here from mime/extension instead of left unclassified.
function fileTypeFromMime(mime, name) {
const prefix = (mime || '').split('/')[0];
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
const ext = extFromName(name).toLowerCase();
if (mime === 'application/pdf' || DOCUMENT_EXT.includes(ext)) return 'document';
return 'file';
}
function FileCard({name, size, mime, sender, removed, onOpen}) {
const ext = extFromName(name);
const [, color] = FILE_TYPE_META[fileTypeFromMime(mime, name)];
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
<span>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
<span style={{backgroundColor: color}}>{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>;
@@ -180,7 +192,7 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : 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} removed={item.availability === 'removed'} /> : null}
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} duration={att && att.duration} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div>
</article>;
+20 -14
View File
@@ -535,14 +535,15 @@ function GroupProfile({topic, myUserId, onToast}) {
<button type="button" onClick={() => navigate('group-my-nickname', topic)}>
<span>群昵称</span><small>{nicknameLabel}</small><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>}
{/* Swapping to a bare <div> when !canEdit used to drop every .group-profile row rule (all of
them are scoped to > button/> label) -- same fix as the avatar row just above: keep
<button>, gate it with disabled like that row already does. */}
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-nickname', topic)}>
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
</button>
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-description', topic)}>
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
</button>
<button type="button" className="group-profile-qr" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => navigate('group-members', topic)}><span>群成员</span><small>共{group.member_count}位成员</small><Icon>chevron_right</Icon></button>
</main>
@@ -637,7 +638,7 @@ const GOVERNANCE_FIELDS = [
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
];
function Governance({topic, tinode, onToast}) {
function Governance({topic, myRole, 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.
@@ -659,6 +660,11 @@ function Governance({topic, tinode, onToast}) {
.catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
// Backend already refuses to actually destroy the group for a non-owner (the same native
// {del hard:true} call it silently downgrades into "remove me from this group" instead) --
// but that response is indistinguishable from a real success, so without this gate a member
// who somehow lands on this page got told "群聊已解散" when they'd really just left it.
if (myRole !== 'owner') return;
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return;
@@ -683,7 +689,7 @@ function Governance({topic, tinode, onToast}) {
<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>
<button type="button" className="group-danger" disabled={dissolving || myRole !== 'owner'} onClick={dissolve}>解散群组</button>
</main>
</div>;
}
@@ -752,7 +758,7 @@ function GroupMembers({topic, onToast}) {
setMembers(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return <div className="group-page">
@@ -787,7 +793,7 @@ function MemberPicker({type, topic, onToast}) {
.then(items => setMembers(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 visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
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 = () => {
@@ -901,7 +907,7 @@ function OwnerSelect({topic, onToast}) {
.then(items => setMembers(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 visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
@@ -1148,7 +1154,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
else if (view === 'group-members') content = <GroupMembers topic={topic} 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 (view === 'group-governance') content = <Governance topic={topic} myRole={myRole} 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} />;
+2
View File
@@ -385,6 +385,8 @@ function MessageActions() {
// 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
+3 -2
View File
@@ -2228,7 +2228,9 @@ class MessagesView extends React.Component {
const messageNodes = [];
let previousFrom = null;
let prevDate = null;
let chatBoxClass = null;
// Set upfront (not just inside the loop below) so an empty (zero-message) conversation still
// gets the base .chat-box class instead of leaving <ul id="scroller"> with no class at all.
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
topic.messages((msg, prev, next, i) => {
let nextFrom = next ? (next.from || 'chan') : null;
@@ -2254,7 +2256,6 @@ class MessagesView extends React.Component {
userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo);
}
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
// Ref for this chat message.
const ref = this.getOrCreateMessageRef(msg.seq);
+5 -4
View File
@@ -3,7 +3,7 @@ import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME, LINK_CONTACT_US } from '../config.js';
import { BASE_APP_NAME } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
@@ -470,9 +470,10 @@ function AppVersion({onToast}) {
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */}
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} />
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信;LINK_CONTACT_US 本身也
没有真实地址(原来指向开源 Tinode 项目自己的域名,是品牌泄漏,已改为 null),所以跟
"用户条款"/"隐私协议"两行一样降级成 toast,不指向任何外部地址。 */}
<MenuRow title="联系我们" onClick={() => onToast('联系我们')} />
</section>
</main>
</div>;
+5 -1
View File
@@ -240,6 +240,10 @@ class AudioPlayer extends React.PureComponent {
this.audioPlayer.pause();
this.setState({playing: false});
} else if (this.audioPlayer.readyState >= 2) {
if (activeAudioPlayer && activeAudioPlayer !== this) {
activeAudioPlayer.pauseForOtherPlayer();
}
activeAudioPlayer = this;
this.audioPlayer.play();
this.setState({playing: true}, this.visualize);
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
@@ -284,7 +288,7 @@ class AudioPlayer extends React.PureComponent {
:
<>
{play}
<div>
<div className="playback-row">
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
</div>
+10 -3
View File
@@ -424,11 +424,18 @@ class AudioRecorder extends React.PureComponent {
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
if (this.props.holdMode) {
// docs/images/内容沟通对话3-语音录音中.png shows a centered floating card (icon + waveform +
// hint), not an inline pill docked in the composer row -- reuses .compose-record-overlay,
// the position:fixed/centered-card CSS that message-composer-view.jsx's (non-wired, demo-only)
// VoiceOverlay already has for this exact look, rather than inventing a second copy of it.
// The waveform itself stays the real canvas visualization (real amplitude data via
// visualize()), not the demo's static bar list -- only the container and text markup change.
return (
<div className={'audio audio-hold' + (this.props.cancelling ? ' cancelling' : '')}>
<div className={'compose-record-overlay' + (this.props.cancelling ? ' cancel' : '')} role="status">
<i className="material-icons">{this.props.cancelling ? 'delete_outline' : 'mic'}</i>
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
<div className="duration">{this.state.duration}</div>
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div>
<strong>{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong>
<small>{this.state.duration}</small>
</div>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default class MetaMessage extends React.PureComponent {
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
bubbleClass += ' date';
bubbleClass += ' deleted';
}
if (!content) {
+100 -88
View File
@@ -2400,9 +2400,9 @@ const FORWARDED_PREVIEW_LENGTH = 84;
const EDIT_PREVIEW_LENGTH = 48;
const MIN_DURATION = 2_000;
const MAX_DURATION = 600_000;
const LINK_CONTACT_US = 'mailto:support@tinode.co';
const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
const LINK_CONTACT_US = null;
const LINK_PRIVACY_POLICY = null;
const LINK_TERMS_OF_SERVICE = null;
const IMMEDIATE_P2P_SUBSCRIPTION = false;
const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];
const QRCODE_SIZE = 128;
@@ -3722,7 +3722,8 @@ const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除'
'gone': '内容不存在或已被删除',
'not found': '内容不存在或已被删除'
};
function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
@@ -4886,7 +4887,7 @@ function updateFavicon(count) {
head.removeChild(oldIcon);
}
head.appendChild(newIcon);
document.title = (count > 0 ? '(' + count + ') ' : '') + 'Tinode';
document.title = (count > 0 ? '(' + count + ') ' : '') + _config_js__WEBPACK_IMPORTED_MODULE_1__.BASE_APP_NAME;
}
function theCard(fn, imageUrl, imageMimeType, note) {
let card = null;
@@ -5470,96 +5471,93 @@ __webpack_require__.r(__webpack_exports__);
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-intl */ "react-intl");
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
/* harmony import */ var tinode_sdk__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! tinode-sdk */ "tinode-sdk");
/* harmony import */ var tinode_sdk__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(tinode_sdk__WEBPACK_IMPORTED_MODULE_2__);
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
function FlatLink({
href,
icon,
children
}) {
return href ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("a", {
href: href,
className: "flat-button",
target: "_blank",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("i", {
className: "material-icons",
children: icon
}, void 0, false), " \xA0", children]
}, void 0, true) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
className: "flat-button",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("i", {
className: "material-icons",
children: icon
}, void 0, false), " \xA0", children]
}, void 0, true);
}
class AccSupportView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
render() {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "scrollable-panel",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "panel-form-column",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("a", {
href: _config_js__WEBPACK_IMPORTED_MODULE_3__.LINK_CONTACT_US,
className: "flat-button",
target: "_blank",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("i", {
className: "material-icons",
children: "email"
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(FlatLink, {
href: _config_js__WEBPACK_IMPORTED_MODULE_2__.LINK_CONTACT_US,
icon: "email",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "link_contact_us",
defaultMessage: "Contact Us",
description: "Ancor text for contacting us by email"
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("a", {
href: _config_js__WEBPACK_IMPORTED_MODULE_3__.LINK_TERMS_OF_SERVICE,
className: "flat-button",
target: "_blank",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("i", {
className: "material-icons",
children: "description"
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(FlatLink, {
href: _config_js__WEBPACK_IMPORTED_MODULE_2__.LINK_TERMS_OF_SERVICE,
icon: "description",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "link_terms_of_service",
defaultMessage: "Terms of Service",
description: "Ancor text for terms of service link"
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("a", {
href: _config_js__WEBPACK_IMPORTED_MODULE_3__.LINK_PRIVACY_POLICY,
className: "flat-button",
target: "_blank",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("i", {
className: "material-icons",
children: "policy"
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(FlatLink, {
href: _config_js__WEBPACK_IMPORTED_MODULE_2__.LINK_PRIVACY_POLICY,
icon: "policy",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "link_privacy_policy",
defaultMessage: "Privacy Policy",
description: "Ancor text for privacy policy link"
}, void 0, false)]
}, void 0, true)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
}, void 0, false)
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "hr"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "panel-form-column",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_client",
defaultMessage: "Client:",
description: "Label for a client version"
}, void 0, false)
}, void 0, false), _config_js__WEBPACK_IMPORTED_MODULE_3__.APP_NAME]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
}, void 0, false), _config_js__WEBPACK_IMPORTED_MODULE_2__.APP_NAME]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_sdk",
defaultMessage: "SDK:"
}, void 0, false)
}, void 0, false), tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Tinode.getLibrary()]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_server",
defaultMessage: "Server:",
description: "Label for a server version"
}, void 0, false)
}, void 0, false), this.props.serverVersion]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: "panel-form-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("label", {
className: "small",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
id: "label_server_address",
defaultMessage: "Server address:"
}, void 0, false)
@@ -5965,13 +5963,14 @@ function EnterpriseRegister() {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("ol", {
className: "auth-steps",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("li", {
className: submitted ? '' : 'active',
className: "active",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
children: "1"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("b", {
children: "填写企业信息"
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("li", {
className: submitted ? 'active' : '',
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
children: "2"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("b", {
@@ -6356,7 +6355,9 @@ function Register({
invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)
}),
placeholder: "请输入邀请码(非必填)",
error: errors.invite
error: errors.invite,
autoCapitalize: "off",
autoCorrect: "off"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("button", {
className: "auth-primary",
type: "submit",
@@ -7664,7 +7665,7 @@ function AddressBook({
}).catch(() => {}).finally(() => setLoading(false));
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriendRequests)('inbound', 'pending', cursor)).then(list => setPendingCount(list.length)).catch(() => {});
}, []);
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.includes(query));
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.toLowerCase().includes(query.toLowerCase()));
const groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name);
return {
@@ -8486,7 +8487,7 @@ function Settings({
className: "people-remark-edit",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("input", {
value: remarkDraft,
maxLength: "20",
maxLength: "64",
onChange: e => setRemarkDraft(e.target.value),
autoFocus: true
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
@@ -9040,10 +9041,18 @@ const FILE_TYPE_META = {
document: ['description', '#e05d44'],
file: ['insert_drive_file', '#8a8f98']
};
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : '';
}
function fileTypeFromMime(mime, name) {
const prefix = (mime || '').split('/')[0];
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
const ext = extFromName(name).toLowerCase();
if (mime === 'application/pdf' || DOCUMENT_EXT.includes(ext)) return 'document';
return 'file';
}
function FileCard({
name,
size,
@@ -9053,11 +9062,15 @@ function FileCard({
onOpen
}) {
const ext = extFromName(name);
const [, color] = FILE_TYPE_META[fileTypeFromMime(mime, name)];
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
className: 'library-file-card' + (removed ? ' removed' : ''),
onClick: removed ? undefined : onOpen,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("span", {
style: {
backgroundColor: color
},
children: ext || (mime || '').split('/')[0].toUpperCase() || '?'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("strong", {
@@ -9206,6 +9219,7 @@ function FavoriteItem({
}, void 0, false) : null, item.type === 'image' || item.type === 'video' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(MediaThumb, {
url: item._thumbUrl,
video: item.type === 'video',
duration: att && att.duration,
onOpen: onOpen,
removed: item.availability === 'removed'
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("footer", {
@@ -11688,9 +11702,10 @@ function GroupProfile({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right"
}, void 0, false)]
}, void 0, true), canEdit ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => navigate('group-nickname', topic),
disabled: !canEdit,
onClick: () => canEdit && navigate('group-nickname', topic),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "群名称"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
@@ -11698,15 +11713,10 @@ function GroupProfile({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right"
}, void 0, false)]
}, void 0, true) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "群名称"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: group.name
}, void 0, false)]
}, void 0, true), canEdit ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => navigate('group-description', topic),
disabled: !canEdit,
onClick: () => canEdit && navigate('group-description', topic),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "群简介"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
@@ -11714,12 +11724,6 @@ function GroupProfile({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right"
}, void 0, false)]
}, void 0, true) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "群简介"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: group.description || '群主很懒,还没有群简介'
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
className: "group-profile-qr",
@@ -11895,6 +11899,7 @@ function GroupQr({
const GOVERNANCE_FIELDS = [['join_approval', '进群验证', '开启后,他人申请加入群聊需群主或管理员同意'], ['mute_all', '全体禁言', '开启后,仅群主和管理员可以发言'], ['member_upload_files', '群成员文件上传', '关闭后,普通成员将无法上传群文件'], ['member_invite', '群成员邀请', '关闭后,仅群主和管理员可以邀请新成员进群'], ['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']];
function Governance({
topic,
myRole,
tinode,
onToast
}) {
@@ -11922,6 +11927,7 @@ function Governance({
}).catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
if (myRole !== 'owner') return;
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return;
@@ -12014,7 +12020,7 @@ function Governance({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
className: "group-danger",
disabled: dissolving,
disabled: dissolving || myRole !== 'owner',
onClick: dissolve,
children: "解散群组"
}, void 0, false)]
@@ -12147,7 +12153,7 @@ function GroupMembers({
setLoadError(true);
});
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
@@ -12214,7 +12220,7 @@ function MemberPicker({
setLoadError(true);
});
}, [topic, mute]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
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 = () => {
@@ -12380,7 +12386,7 @@ function OwnerSelect({
setLoadError(true);
});
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => {
@@ -12896,6 +12902,7 @@ function GroupManagementView({
onToast: setToast
}, void 0, false);else if (view === 'group-governance') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Governance, {
topic: topic,
myRole: myRole,
tinode: tinode,
onToast: setToast
}, void 0, false);else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(AdminList, {
@@ -13643,6 +13650,8 @@ function MessageActions() {
}, void 0, true);
}
const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
@@ -15835,9 +15844,7 @@ function AppVersion({
onClick: () => onToast('隐私协议')
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, {
title: "联系我们",
onClick: () => {
window.location.href = _config_js__WEBPACK_IMPORTED_MODULE_4__.LINK_CONTACT_US;
}
onClick: () => onToast('联系我们')
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
@@ -22698,6 +22705,10 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
playing: false
});
} else if (this.audioPlayer.readyState >= 2) {
if (activeAudioPlayer && activeAudioPlayer !== this) {
activeAudioPlayer.pauseForOtherPlayer();
}
activeAudioPlayer = this;
this.audioPlayer.play();
this.setState({
playing: true
@@ -22745,6 +22756,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
}, void 0, false), play]
}, void 0, true) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.Fragment, {
children: [play, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("div", {
className: "playback-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("canvas", {
className: "playback",
ref: this.canvasRef,
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -1969,7 +1969,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
const messageNodes = [];
let previousFrom = null;
let prevDate = null;
let chatBoxClass = null;
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
topic.messages((msg, prev, next, i) => {
let nextFrom = next ? next.from || 'chan' : null;
let sequence = 'single';
@@ -1994,7 +1994,6 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
userName = user.public.fn;
userAvatar = (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_17__.makeImageUrl)(user.public.photo);
}
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
const ref = this.getOrCreateMessageRef(msg.seq);
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
if (!replyToSeq || isNaN(replyToSeq)) {
@@ -3175,7 +3174,7 @@ class MetaMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
className: "gray",
children: this.props.deletedText || '消息已删除'
}, void 0, false);
bubbleClass += ' date';
bubbleClass += ' deleted';
}
if (!content) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.Fragment, {
File diff suppressed because one or more lines are too long
+9 -7
View File
@@ -352,15 +352,17 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
if (this.props.holdMode) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: 'audio audio-hold' + (this.props.cancelling ? ' cancelling' : ''),
children: [this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", {
className: 'compose-record-overlay' + (this.props.cancelling ? ' cancel' : ''),
role: "status",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("i", {
className: "material-icons",
children: this.props.cancelling ? 'delete_outline' : 'mic'
}, void 0, false), this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", {
ref: this.canvasRef
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: "duration",
children: this.state.duration
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: "audio-hold-hint",
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("strong", {
children: this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("small", {
children: this.state.duration
}, void 0, false)]
}, void 0, true);
}
File diff suppressed because one or more lines are too long