第八轮最小粒度测试:修复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 { .report-author {
display: grid; 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; align-items: center;
gap: 0.7rem; gap: 0.7rem;
} }
@@ -2117,6 +2122,8 @@ input[type="email"],
input[type="password"], input[type="password"],
input[type="search"], input[type="search"],
input[type="tel"], input[type="tel"],
input[type="date"],
input[type="time"],
textarea { textarea {
height: 2.5rem; height: 2.5rem;
border-bottom: 1px solid var(--clr-input-border); border-bottom: 1px solid var(--clr-input-border);
@@ -2128,6 +2135,8 @@ input[type="text"],
input[type="email"], input[type="email"],
input[type="password"], input[type="password"],
input[type="tel"], input[type="tel"],
input[type="date"],
input[type="time"],
textarea { textarea {
width: 100%; width: 100%;
} }
@@ -2137,7 +2146,9 @@ input[type="text"]:focus,
input[type="email"]:focus, input[type="email"]:focus,
input[type="password"]:focus, input[type="password"]:focus,
input[type="search"]: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); border-bottom: 1px solid var(--clr-input-border-focus);
box-shadow: 0 1px 0 var(--clr-input-shadow-focus); box-shadow: 0 1px 0 var(--clr-input-shadow-focus);
} }
@@ -3114,7 +3125,7 @@ img.channel {
} }
/* Online-offline indicator positioned inside avatar-box */ /* Online-offline indicator positioned inside avatar-box */
.avatar-box .online, .offline { .avatar-box .online, .avatar-box .offline {
position: absolute; position: absolute;
width: 0.75rem; width: 0.75rem;
min-width: 0.75rem; min-width: 0.75rem;
@@ -3582,9 +3593,15 @@ img.channel {
.select-checkbox.blue { .select-checkbox.blue {
color: var(--clr-primary-accent); color: var(--clr-primary-accent);
} }
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row regardless of /* Flexbox auto-margin trick: pins the checkbox to the far left of the row for sent (.right)
whether the message itself is left- or right-aligned (docs/images/多选-选中对话框.png). */ messages, where it would otherwise cluster next to the bubble at the row's right edge along with
.chat-box li .select-checkbox { 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; margin-right: auto;
} }
@@ -3637,12 +3654,29 @@ img.channel {
.batch-forward-confirm { .batch-forward-confirm {
max-width: 24rem; 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 { .batch-forward-target {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.6rem 0; 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 的原生边框/宽度,否则会缩成一个小方框。 */ /* 从只读 div 改成可输入的留言框后,要显式清掉 input 的原生边框/宽度,否则会缩成一个小方框。 */
.batch-forward-comment { .batch-forward-comment {
box-sizing: border-box; box-sizing: border-box;
@@ -3900,7 +3934,18 @@ img.channel {
font-size: 1.6rem; 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; color: var(--clr-text-tertiary)!important;
} }
@@ -4221,7 +4266,7 @@ button.full-screen {
min-width: unset; min-width: unset;
} }
.minimized button.full-screen { .minimized:not(.audio-minimized) button.full-screen {
top: 1rem; top: 1rem;
left: 1rem; left: 1rem;
width: 2rem; width: 2rem;
@@ -4236,7 +4281,7 @@ button.full-screen .material-icons {
font-size: x-large; font-size: x-large;
} }
.minimized button.full-screen .material-icons { .minimized:not(.audio-minimized) button.full-screen .material-icons {
font-size: large; font-size: large;
} }
@@ -4362,7 +4407,7 @@ video {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.caller-card .trusted-badge { .caller-card .trusted-badge, .call-peer-card h1 .trusted-badge {
display: inline-block; display: inline-block;
} }
@@ -5497,29 +5542,12 @@ ul.tabbar li.active > a {
margin-right: 0; margin-right: 0;
} }
.audio-hold { /* .compose-record-overlay (8588 below) replaced this holdMode presentation -- see
flex: 1; audio-recorder.jsx's holdMode render for why. */
justify-content: center; .compose-record-overlay canvas {
gap: 0.6rem;
}
.audio-hold canvas {
width: 6rem; width: 6rem;
margin-right: 0; height: 1.8rem;
} margin-top: 0.4rem;
.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;
} }
.audio .duration { .audio .duration {
@@ -5550,7 +5578,11 @@ ul.tabbar li.active > a {
display: block; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -6786,6 +6818,17 @@ div.toast.show {
opacity: 0; 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 { .auth-pin-content .auth-primary {
width: min(100%, 24rem); width: min(100%, 24rem);
margin-top: 3rem; margin-top: 3rem;
@@ -7056,6 +7099,7 @@ div.toast.show {
margin-top: 0.4rem; margin-top: 0.4rem;
overflow: hidden; overflow: hidden;
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.3;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -7552,22 +7596,6 @@ div.toast.show {
line-height: 1.55; 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 { .system-notification-card dl {
margin: 0; margin: 0;
} }
@@ -9162,7 +9190,8 @@ div.toast.show {
.group-list button > span:nth-child(2), .group-list button > span:nth-child(2),
.group-request-list article > div, .group-request-list article > div,
.group-admin-page article > span:nth-child(2), .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; display: flex;
min-width: 0; min-width: 0;
flex-direction: column; flex-direction: column;
@@ -9353,15 +9382,6 @@ div.toast.show {
text-transform: none; 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, .people-qr-page main,
.group-qr-page main { .group-qr-page main {
display: flex; display: flex;
@@ -9684,8 +9704,12 @@ div.toast.show {
.people-settings section small, .people-settings section small,
.group-settings section small { .group-settings section small {
min-width: 0;
margin-left: auto; margin-left: auto;
overflow: hidden;
color: #949ba4; color: #949ba4;
text-overflow: ellipsis;
white-space: nowrap;
} }
.people-switch, .people-switch,
@@ -10212,20 +10236,6 @@ div.toast.show {
line-height: 1.8; 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 { .group-announcement-detail > small {
display: block; display: block;
margin-top: 1rem; margin-top: 1rem;
@@ -10309,12 +10319,15 @@ div.toast.show {
flex: 0 0 3.6rem; 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 { .group-member-tile-action {
display: grid; display: grid;
place-items: center; place-items: center;
width: 3rem; width: 3.6rem;
height: 3rem; height: 3.6rem;
border-radius: 0.45rem; border-radius: 0.45rem;
color: #98a0aa; color: #98a0aa;
background: #f0f2f5; background: #f0f2f5;
@@ -10569,11 +10582,6 @@ div.toast.show {
text-transform: none; text-transform: none;
} }
.group-member-picker > button > span:nth-child(2) {
display: flex;
flex-direction: column;
}
.group-check { .group-check {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -10866,7 +10874,7 @@ div.toast.show {
.library-favorite-content > footer { .library-favorite-content > footer {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto 2rem; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.65rem; margin-top: 0.65rem;
@@ -10959,6 +10967,32 @@ div.toast.show {
font-style: normal; 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 /* 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 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 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 { .library-voice-card {
display: grid; display: flex;
grid-template-columns: 2rem minmax(4rem, 12rem) auto;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
width: min(100%, 20rem); width: min(100%, 20rem);
@@ -11501,8 +11534,39 @@ div.toast.show {
background: #fff; 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-files .library-file-card,
.library-record-links .library-link-card { .library-record-links .library-link-text {
margin-bottom: 0.7rem; margin-bottom: 0.7rem;
} }
@@ -11851,6 +11915,10 @@ div.toast.show {
margin: 1.3rem 0 0.45rem; margin: 1.3rem 0 0.45rem;
color: #fff; color: #fff;
font-size: 1.55rem; font-size: 1.55rem;
max-width: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.call-peer-card p { .call-peer-card p {
@@ -12606,6 +12674,7 @@ div.toast.show {
#video-container.minimized { #video-container.minimized {
flex-direction: row; flex-direction: row;
left: 0; left: 0;
right: auto;
top: var(--caption-panel-height); top: var(--caption-panel-height);
width: 100%; width: 100%;
height: 11rem; 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>Tinode</title> <title>MeetingIM</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web, version for developers" /> <meta name="application-name" content="Tinode Web, version for developers" />
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Tinode</title> <title>MeetingIM</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web" /> <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). // Maximum duration of an audio recording in milliseconds (10 min).
export const MAX_DURATION = 600_000; export const MAX_DURATION = 600_000;
// Link for "Contact Us". // No real destination exists for these (this is a private multi-tenant product, not the open-source
export const LINK_CONTACT_US = 'mailto:support@tinode.co'; // 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
// Link to Privacy Policy. // own placeholder 用户协议/隐私政策 links).
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html'; export const LINK_CONTACT_US = null;
export const LINK_PRIVACY_POLICY = null;
// Link to Terms of Service. export const LINK_TERMS_OF_SERVICE = null;
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
// When user starts a P2P topic, automatic subscription does not // When user starts a P2P topic, automatic subscription does not
// happen until the first message. Setting this option to true enables // 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': '文件大小超出限制', 'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试', 'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作', 'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除' 'gone': '内容不存在或已被删除',
'not found': '内容不存在或已被删除'
}; };
export function uploadLargeFile(tinode, file, onProgress) { export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken(); const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
+2 -2
View File
@@ -1,7 +1,7 @@
// Odds and ends // Odds and ends
import { Tinode } from 'tinode-sdk'; 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` // 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 // 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); 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. // Create theCard which represents user's or topic's "public" info.
+18 -18
View File
@@ -2,27 +2,31 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; 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'; 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 { export default class AccSupportView extends React.PureComponent {
render() { render() {
return ( return (
<div className="scrollable-panel"> <div className="scrollable-panel">
<div className="panel-form-column"> <div className="panel-form-column">
<a href={LINK_CONTACT_US} className="flat-button" target="_blank"> <FlatLink href={LINK_CONTACT_US} icon="email">
<i className="material-icons">email</i> &nbsp;<FormattedMessage id="link_contact_us" <FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
defaultMessage="Contact Us" description="Ancor text for contacting us by email" /> </FlatLink>
</a> <FlatLink href={LINK_TERMS_OF_SERVICE} icon="description">
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank"> <FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
<i className="material-icons">description</i> &nbsp;<FormattedMessage id="link_terms_of_service" </FlatLink>
defaultMessage="Terms of Service" description="Ancor text for terms of service link" /> <FlatLink href={LINK_PRIVACY_POLICY} icon="policy">
</a> <FormattedMessage id="link_privacy_policy" defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank"> </FlatLink>
<i className="material-icons">policy</i> &nbsp;<FormattedMessage id="link_privacy_policy"
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</a>
</div> </div>
<div className="hr" /> <div className="hr" />
<div className="panel-form-column"> <div className="panel-form-column">
@@ -31,10 +35,6 @@ export default class AccSupportView extends React.PureComponent {
description="Label for a client version" /></label> description="Label for a client version" /></label>
{APP_NAME} {APP_NAME}
</div> </div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
{Tinode.getLibrary()}
</div>
<div className="panel-form-row"> <div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:" <label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
description="Label for a server version" /></label> description="Label for a server version" /></label>
+11 -3
View File
@@ -232,9 +232,14 @@ function EnterpriseRegister() {
return <div className="auth-page"> return <div className="auth-page">
<Header title="企业注册" /> <Header title="企业注册" />
<main className="auth-content auth-content-narrow"> <main className="auth-content auth-content-narrow">
{/* .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"> <ol className="auth-steps">
<li className={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li> <li className="active"><span>1</span><b>填写企业信息</b></li>
<li><span>2</span><b>确定信息</b></li> <li className={submitted ? 'active' : ''}><span>2</span><b>确定信息</b></li>
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li> <li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
</ol> </ol>
<form className="auth-form" onSubmit={submit}> <form className="auth-form" onSubmit={submit}>
@@ -462,8 +467,11 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId} action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
onError={msg => setErrors(e => ({...e, code: msg}))} />} /> onError={msg => setErrors(e => ({...e, code: msg}))} />} />
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} /> <PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
{/* 邀请码大小写敏感(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)})} <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}> <button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'} {submitting ? '注册中…' : '注册'}
</button> </button>
+4 -2
View File
@@ -121,7 +121,7 @@ function AddressBook({myUserId, onTopicSelected}) {
fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor)) fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor))
.then(list => setPendingCount(list.length)).catch(() => {}); .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 groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name); const key = alphaInitial(item.remark || item.display_name);
return {...result, [key]: (result[key] || []).concat(item)}; return {...result, [key]: (result[key] || []).concat(item)};
@@ -548,7 +548,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
}} /></div> }} /></div>
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button> <button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
{editingRemark ? {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>} <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 => { <div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
if (value) { navigate('block-confirm', uid); return; } 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). // 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 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) { function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || ''); const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : ''; 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}) { function FileCard({name, size, mime, sender, removed, onOpen}) {
const ext = extFromName(name); 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}> 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> <div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>} {removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
</button>; </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 === '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)} {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} 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> <footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div> </div>
</article>; </article>;
+20 -14
View File
@@ -535,14 +535,15 @@ function GroupProfile({topic, myUserId, onToast}) {
<button type="button" onClick={() => navigate('group-my-nickname', topic)}> <button type="button" onClick={() => navigate('group-my-nickname', topic)}>
<span>群昵称</span><small>{nicknameLabel}</small><Icon>chevron_right</Icon> <span>群昵称</span><small>{nicknameLabel}</small><Icon>chevron_right</Icon>
</button> </button>
{canEdit ? <button type="button" onClick={() => navigate('group-nickname', topic)}> {/* Swapping to a bare <div> when !canEdit used to drop every .group-profile row rule (all of
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon> them are scoped to > button/> label) -- same fix as the avatar row just above: keep
</button> : <button>, gate it with disabled like that row already does. */}
<div><span>群名称</span><small>{group.name}</small></div>} <button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-nickname', topic)}>
{canEdit ? <button type="button" onClick={() => navigate('group-description', topic)}> <span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon> </button>
</button> : <button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-description', topic)}>
<div><span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small></div>} <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" 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> <button type="button" onClick={() => navigate('group-members', topic)}><span>群成员</span><small>共{group.member_count}位成员</small><Icon>chevron_right</Icon></button>
</main> </main>
@@ -637,7 +638,7 @@ const GOVERNANCE_FIELDS = [
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。'] ['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 // 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 // `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. // 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 || '设置失败')); .catch(err => onToast(err.message || '设置失败'));
}; };
const dissolve = () => { 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; if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null; const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return; if (!t) return;
@@ -683,7 +689,7 @@ function Governance({topic, tinode, onToast}) {
<span><strong>{label}</strong><small>{desc}</small></span> <span><strong>{label}</strong><small>{desc}</small></span>
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} /> <Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
</div>)}</section> </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> </main>
</div>; </div>;
} }
@@ -752,7 +758,7 @@ function GroupMembers({topic, onToast}) {
setMembers(items); setMembers(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); }); }).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]); }, [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 groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : ''; const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return <div className="group-page"> return <div className="group-page">
@@ -787,7 +793,7 @@ function MemberPicker({type, topic, onToast}) {
.then(items => setMembers(items.filter(m => m.eligible !== false))) .then(items => setMembers(items.filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); }); .catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, mute]); }, [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 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 toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => { const done = () => {
@@ -901,7 +907,7 @@ function OwnerSelect({topic, onToast}) {
.then(items => setMembers(items.filter(m => m.eligible !== false))) .then(items => setMembers(items.filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); }); .catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]); }, [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 admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin'); const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); }; 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-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-members') content = <GroupMembers topic={topic} onToast={setToast} />;
else if (view === 'group-qr') content = <GroupQr 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 (['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-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-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, // 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。 // 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
const NOTIFICATION_TEXT = { const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.member.joined': '有成员加入了群聊', 'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊', 'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新', 'group.member.updated': '群成员资料有更新',
+3 -2
View File
@@ -2228,7 +2228,9 @@ class MessagesView extends React.Component {
const messageNodes = []; const messageNodes = [];
let previousFrom = null; let previousFrom = null;
let prevDate = 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) => { topic.messages((msg, prev, next, i) => {
let nextFrom = next ? (next.from || 'chan') : null; let nextFrom = next ? (next.from || 'chan') : null;
@@ -2254,7 +2256,6 @@ class MessagesView extends React.Component {
userName = user.public.fn; userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo); userAvatar = makeImageUrl(user.public.photo);
} }
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
// Ref for this chat message. // Ref for this chat message.
const ref = this.getOrCreateMessageRef(msg.seq); 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 HashNavigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.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 { PACKAGE_VERSION } from '../version.js';
import { import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile, getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
@@ -470,9 +470,10 @@ function AppVersion({onToast}) {
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} /> <MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样, {/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。 那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的 图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信;LINK_CONTACT_US 本身也
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */} 没有真实地址(原来指向开源 Tinode 项目自己的域名,是品牌泄漏,已改为 null),所以跟
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} /> "用户条款"/"隐私协议"两行一样降级成 toast,不指向任何外部地址。 */}
<MenuRow title="联系我们" onClick={() => onToast('联系我们')} />
</section> </section>
</main> </main>
</div>; </div>;
+5 -1
View File
@@ -240,6 +240,10 @@ class AudioPlayer extends React.PureComponent {
this.audioPlayer.pause(); this.audioPlayer.pause();
this.setState({playing: false}); this.setState({playing: false});
} else if (this.audioPlayer.readyState >= 2) { } else if (this.audioPlayer.readyState >= 2) {
if (activeAudioPlayer && activeAudioPlayer !== this) {
activeAudioPlayer.pauseForOtherPlayer();
}
activeAudioPlayer = this;
this.audioPlayer.play(); this.audioPlayer.play();
this.setState({playing: true}, this.visualize); this.setState({playing: true}, this.visualize);
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身 // 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
@@ -284,7 +288,7 @@ class AudioPlayer extends React.PureComponent {
: :
<> <>
{play} {play}
<div> <div className="playback-row">
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} /> <canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
<div className="timer">{this.state.currentTime}/{this.state.duration}</div> <div className="timer">{this.state.currentTime}/{this.state.duration}</div>
</div> </div>
+10 -3
View File
@@ -424,11 +424,18 @@ class AudioRecorder extends React.PureComponent {
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子 // 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。 // (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
if (this.props.holdMode) { 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 ( 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} {this.state.recording ? <canvas ref={this.canvasRef} /> : null}
<div className="duration">{this.state.duration}</div> <strong>{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong>
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div> <small>{this.state.duration}</small>
</div> </div>
); );
} }
+1 -1
View File
@@ -19,7 +19,7 @@ export default class MetaMessage extends React.PureComponent {
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上 // 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。 // 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>; content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
bubbleClass += ' date'; bubbleClass += ' deleted';
} }
if (!content) { if (!content) {
+100 -88
View File
@@ -2400,9 +2400,9 @@ const FORWARDED_PREVIEW_LENGTH = 84;
const EDIT_PREVIEW_LENGTH = 48; const EDIT_PREVIEW_LENGTH = 48;
const MIN_DURATION = 2_000; const MIN_DURATION = 2_000;
const MAX_DURATION = 600_000; const MAX_DURATION = 600_000;
const LINK_CONTACT_US = 'mailto:support@tinode.co'; const LINK_CONTACT_US = null;
const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html'; const LINK_PRIVACY_POLICY = null;
const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html'; const LINK_TERMS_OF_SERVICE = null;
const IMMEDIATE_P2P_SUBSCRIPTION = false; const IMMEDIATE_P2P_SUBSCRIPTION = false;
const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps']; const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];
const QRCODE_SIZE = 128; const QRCODE_SIZE = 128;
@@ -3722,7 +3722,8 @@ const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制', 'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试', 'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作', 'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除' 'gone': '内容不存在或已被删除',
'not found': '内容不存在或已被删除'
}; };
function uploadLargeFile(tinode, file, onProgress) { function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken(); const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
@@ -4886,7 +4887,7 @@ function updateFavicon(count) {
head.removeChild(oldIcon); head.removeChild(oldIcon);
} }
head.appendChild(newIcon); 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) { function theCard(fn, imageUrl, imageMimeType, note) {
let card = null; 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__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__ = __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 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 _config_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
/* harmony import */ var tinode_sdk__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(tinode_sdk__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
/* 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");
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) { class AccSupportView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
render() { 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", 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", className: "panel-form-column",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("a", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(FlatLink, {
href: _config_js__WEBPACK_IMPORTED_MODULE_3__.LINK_CONTACT_US, href: _config_js__WEBPACK_IMPORTED_MODULE_2__.LINK_CONTACT_US,
className: "flat-button", icon: "email",
target: "_blank", children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
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, {
id: "link_contact_us", id: "link_contact_us",
defaultMessage: "Contact Us", defaultMessage: "Contact Us",
description: "Ancor text for contacting us by email" description: "Ancor text for contacting us by email"
}, void 0, false)] }, void 0, false)
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("a", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(FlatLink, {
href: _config_js__WEBPACK_IMPORTED_MODULE_3__.LINK_TERMS_OF_SERVICE, href: _config_js__WEBPACK_IMPORTED_MODULE_2__.LINK_TERMS_OF_SERVICE,
className: "flat-button", icon: "description",
target: "_blank", children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
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, {
id: "link_terms_of_service", id: "link_terms_of_service",
defaultMessage: "Terms of Service", defaultMessage: "Terms of Service",
description: "Ancor text for terms of service link" description: "Ancor text for terms of service link"
}, void 0, false)] }, void 0, false)
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("a", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(FlatLink, {
href: _config_js__WEBPACK_IMPORTED_MODULE_3__.LINK_PRIVACY_POLICY, href: _config_js__WEBPACK_IMPORTED_MODULE_2__.LINK_PRIVACY_POLICY,
className: "flat-button", icon: "policy",
target: "_blank", children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
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, {
id: "link_privacy_policy", id: "link_privacy_policy",
defaultMessage: "Privacy Policy", defaultMessage: "Privacy Policy",
description: "Ancor text for privacy policy link" description: "Ancor text for privacy policy link"
}, void 0, false)] }, void 0, false)
}, void 0, true)] }, void 0, false)]
}, 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: "hr" 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", 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", 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", 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", id: "label_client",
defaultMessage: "Client:", defaultMessage: "Client:",
description: "Label for a client version" description: "Label for a client version"
}, void 0, false) }, void 0, false)
}, void 0, false), _config_js__WEBPACK_IMPORTED_MODULE_3__.APP_NAME] }, void 0, false), _config_js__WEBPACK_IMPORTED_MODULE_2__.APP_NAME]
}, 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", 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", 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_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, {
id: "label_server", id: "label_server",
defaultMessage: "Server:", defaultMessage: "Server:",
description: "Label for a server version" description: "Label for a server version"
}, void 0, false) }, void 0, false)
}, void 0, false), this.props.serverVersion] }, 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", 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", 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", id: "label_server_address",
defaultMessage: "Server address:" defaultMessage: "Server address:"
}, void 0, false) }, void 0, false)
@@ -5965,13 +5963,14 @@ function EnterpriseRegister() {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("ol", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("ol", {
className: "auth-steps", className: "auth-steps",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("li", { 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: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
children: "1" children: "1"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("b", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("b", {
children: "填写企业信息" children: "填写企业信息"
}, void 0, false)] }, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("li", { }, 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: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("span", {
children: "2" children: "2"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("b", { }, 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) invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)
}), }),
placeholder: "请输入邀请码(非必填)", 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", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("button", {
className: "auth-primary", className: "auth-primary",
type: "submit", type: "submit",
@@ -7664,7 +7665,7 @@ function AddressBook({
}).catch(() => {}).finally(() => setLoading(false)); }).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(() => {}); (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 groups = visible.reduce((result, item) => {
const key = alphaInitial(item.remark || item.display_name); const key = alphaInitial(item.remark || item.display_name);
return { return {
@@ -8486,7 +8487,7 @@ function Settings({
className: "people-remark-edit", className: "people-remark-edit",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("input", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("input", {
value: remarkDraft, value: remarkDraft,
maxLength: "20", maxLength: "64",
onChange: e => setRemarkDraft(e.target.value), onChange: e => setRemarkDraft(e.target.value),
autoFocus: true autoFocus: true
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
@@ -9040,10 +9041,18 @@ const FILE_TYPE_META = {
document: ['description', '#e05d44'], document: ['description', '#e05d44'],
file: ['insert_drive_file', '#8a8f98'] file: ['insert_drive_file', '#8a8f98']
}; };
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
function extFromName(name) { function extFromName(name) {
const m = /\.([a-zA-Z0-9]+)$/.exec(name || ''); const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
return m ? m[1].toUpperCase() : ''; 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({ function FileCard({
name, name,
size, size,
@@ -9053,11 +9062,15 @@ function FileCard({
onOpen onOpen
}) { }) {
const ext = extFromName(name); const ext = extFromName(name);
const [, color] = FILE_TYPE_META[fileTypeFromMime(mime, name)];
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", { return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button", type: "button",
className: 'library-file-card' + (removed ? ' removed' : ''), className: 'library-file-card' + (removed ? ' removed' : ''),
onClick: removed ? undefined : onOpen, onClick: removed ? undefined : onOpen,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("span", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("span", {
style: {
backgroundColor: color
},
children: ext || (mime || '').split('/')[0].toUpperCase() || '?' children: ext || (mime || '').split('/')[0].toUpperCase() || '?'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", { }, 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", { 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, { }, void 0, false) : null, item.type === 'image' || item.type === 'video' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(MediaThumb, {
url: item._thumbUrl, url: item._thumbUrl,
video: item.type === 'video', video: item.type === 'video',
duration: att && att.duration,
onOpen: onOpen, onOpen: onOpen,
removed: item.availability === 'removed' removed: item.availability === 'removed'
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("footer", { }, 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, { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right" children: "chevron_right"
}, void 0, false)] }, 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", 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: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "群名称" children: "群名称"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", { }, 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, { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right" children: "chevron_right"
}, void 0, false)] }, void 0, false)]
}, void 0, true) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", { }, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
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", {
type: "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: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "群简介" children: "群简介"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", { }, 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, { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right" children: "chevron_right"
}, void 0, false)] }, 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", { }, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button", type: "button",
className: "group-profile-qr", 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', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']]; const GOVERNANCE_FIELDS = [['join_approval', '进群验证', '开启后,他人申请加入群聊需群主或管理员同意'], ['mute_all', '全体禁言', '开启后,仅群主和管理员可以发言'], ['member_upload_files', '群成员文件上传', '关闭后,普通成员将无法上传群文件'], ['member_invite', '群成员邀请', '关闭后,仅群主和管理员可以邀请新成员进群'], ['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']];
function Governance({ function Governance({
topic, topic,
myRole,
tinode, tinode,
onToast onToast
}) { }) {
@@ -11922,6 +11927,7 @@ function Governance({
}).catch(err => onToast(err.message || '设置失败')); }).catch(err => onToast(err.message || '设置失败'));
}; };
const dissolve = () => { const dissolve = () => {
if (myRole !== 'owner') return;
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return; if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null; const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return; if (!t) return;
@@ -12014,7 +12020,7 @@ function Governance({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button", type: "button",
className: "group-danger", className: "group-danger",
disabled: dissolving, disabled: dissolving || myRole !== 'owner',
onClick: dissolve, onClick: dissolve,
children: "解散群组" children: "解散群组"
}, void 0, false)] }, void 0, false)]
@@ -12147,7 +12153,7 @@ function GroupMembers({
setLoadError(true); setLoadError(true);
}); });
}, [topic]); }, [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 groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : ''; const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", { return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
@@ -12214,7 +12220,7 @@ function MemberPicker({
setLoadError(true); setLoadError(true);
}); });
}, [topic, mute]); }, [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 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 toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => { const done = () => {
@@ -12380,7 +12386,7 @@ function OwnerSelect({
setLoadError(true); setLoadError(true);
}); });
}, [topic]); }, [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 admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin'); const others = visible.filter(m => m.role !== 'admin');
const reset = () => { const reset = () => {
@@ -12896,6 +12902,7 @@ function GroupManagementView({
onToast: setToast onToast: setToast
}, void 0, false);else if (view === 'group-governance') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Governance, { }, void 0, false);else if (view === 'group-governance') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Governance, {
topic: topic, topic: topic,
myRole: myRole,
tinode: tinode, tinode: tinode,
onToast: setToast 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, { }, 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); }, void 0, true);
} }
const NOTIFICATION_TEXT = { const NOTIFICATION_TEXT = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.member.joined': '有成员加入了群聊', 'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊', 'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新', 'group.member.updated': '群成员资料有更新',
@@ -15835,9 +15844,7 @@ function AppVersion({
onClick: () => onToast('隐私协议') onClick: () => onToast('隐私协议')
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, { }, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, {
title: "联系我们", title: "联系我们",
onClick: () => { onClick: () => onToast('联系我们')
window.location.href = _config_js__WEBPACK_IMPORTED_MODULE_4__.LINK_CONTACT_US;
}
}, void 0, false)] }, void 0, false)]
}, void 0, true)] }, void 0, true)]
}, void 0, true)] }, void 0, true)]
@@ -22698,6 +22705,10 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
playing: false playing: false
}); });
} else if (this.audioPlayer.readyState >= 2) { } else if (this.audioPlayer.readyState >= 2) {
if (activeAudioPlayer && activeAudioPlayer !== this) {
activeAudioPlayer.pauseForOtherPlayer();
}
activeAudioPlayer = this;
this.audioPlayer.play(); this.audioPlayer.play();
this.setState({ this.setState({
playing: true playing: true
@@ -22745,6 +22756,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
}, void 0, false), play] }, 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, { }, 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", { 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", { children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("canvas", {
className: "playback", className: "playback",
ref: this.canvasRef, 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 = []; const messageNodes = [];
let previousFrom = null; let previousFrom = null;
let prevDate = null; let prevDate = null;
let chatBoxClass = null; let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
topic.messages((msg, prev, next, i) => { topic.messages((msg, prev, next, i) => {
let nextFrom = next ? next.from || 'chan' : null; let nextFrom = next ? next.from || 'chan' : null;
let sequence = 'single'; let sequence = 'single';
@@ -1994,7 +1994,6 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
userName = user.public.fn; userName = user.public.fn;
userAvatar = (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_17__.makeImageUrl)(user.public.photo); 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); const ref = this.getOrCreateMessageRef(msg.seq);
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null; let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
if (!replyToSeq || isNaN(replyToSeq)) { if (!replyToSeq || isNaN(replyToSeq)) {
@@ -3175,7 +3174,7 @@ class MetaMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
className: "gray", className: "gray",
children: this.props.deletedText || '消息已删除' children: this.props.deletedText || '消息已删除'
}, void 0, false); }, void 0, false);
bubbleClass += ' date'; bubbleClass += ' deleted';
} }
if (!content) { if (!content) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.Fragment, { 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'); const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
if (this.props.holdMode) { if (this.props.holdMode) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", { return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: 'audio audio-hold' + (this.props.cancelling ? ' cancelling' : ''), className: 'compose-record-overlay' + (this.props.cancelling ? ' cancel' : ''),
children: [this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", { 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 ref: this.canvasRef
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", { }, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("strong", {
className: "duration",
children: this.state.duration
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
className: "audio-hold-hint",
children: this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送' 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, false)]
}, void 0, true); }, void 0, true);
} }
File diff suppressed because one or more lines are too long