第八轮最小粒度测试:修复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:
co-authored by
Claude Sonnet 5
parent
5e173c55e8
commit
d759f67b11
+7
-8
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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> {children}</a> :
|
||||
<span className="flat-button"><i className="material-icons">{icon}</i> {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> <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> <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> <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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': '群成员资料有更新',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user