第七轮深度测试:修复7个agent发现的12处前端问题,重点是列表分页截断与呼叫/播放状态机

- 通话状态机(Ch8):canStartCall 补齐通话中重复发起的防护,callShouldStart 补齐放弃/失败路径的清理
- 分页截断(Ch5-6):新增 fetchAllPages 辅助函数,修复通讯录/黑名单/好友申请/群成员/管理员/禁言等
  11 处列表只读首页导致真实成员被遗漏的问题;群角色判断改用不受分页影响的 getGroup().my_role
- 消息与媒体(Ch3-4/Ch7/Ch9):录音超时污染时长、手动重试丢失 head 导致消息被错误分类、批量删除
  未取消上传中附件、语音播放器补齐同时只能播一条、workplace-collaboration-view 冗余重试请求
- 独立密码/青少年模式(Ch10-11):设置密码后按调用来源判断是否应自动开启青少年模式,避免关闭流程
  中途"忘记密码"被误当成开启;注销账号确认文案补充群主后果说明

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-05 12:18:25 +08:00
co-authored by Claude Sonnet 5
parent f42b8536e5
commit bede45fd7f
19 changed files with 281 additions and 90 deletions
+21
View File
@@ -181,6 +181,27 @@ export function translateReason(reason, details) {
const idem = () => ({'Idempotency-Key': requestId()});
// Most list endpoints here cap out at 20-50 items per page (some as low as 30, e.g. group
// members against a 128-member cap) and round7 testing found several call sites only ever
// fetched the first page -- for things like "am I this group's owner/admin" or the admin
// candidate list, a real owner/admin sitting past page 1 went undetected entirely rather than
// just showing an incomplete list. Empty/repeated next_cursor means done; this app's list
// responses haven't been observed to send a separate has_more that disagrees with that.
export async function fetchAllPages(fetchPage, startCursor = '') {
let cursor = startCursor;
let items = [];
for (;;) {
const page = await fetchPage(cursor);
items = items.concat((page && page.items) || []);
const nextCursor = (page && page.next_cursor) || '';
if (!nextCursor || nextCursor === cursor) {
break;
}
cursor = nextCursor;
}
return items;
}
export const imGet = (path, opts) => imRequest(path, opts);
export const imPost = (path, body, opts = {}) => imRequest(path, {method: 'POST', body, headers: idem(), ...opts});
export const imPatch = (path, body, opts = {}) => imRequest(path, {method: 'PATCH', body, headers: idem(), ...opts});
+35 -8
View File
@@ -14,7 +14,9 @@ const INVITE_RE = /^[A-Za-z]{5}$/;
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const navigate = view => HashNavigation.navigateTo(view);
// params is optional and additive only -- every existing call site that passes just `view` keeps
// producing the exact same bare hash as the old `HashNavigation.navigateTo(view)` did.
const navigate = (view, params) => HashNavigation.navigateTo(HashNavigation.composeUrlHash([view], params || {}));
function Icon({children, className = ''}) {
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
@@ -529,7 +531,13 @@ function PasswordReset() {
// successful verify, stashes the resulting action_token for the next screen to spend.
const IDENTITY_CHECK_SCENE = 'set_independent_password';
function IdentityCheck({authenticated}) {
function IdentityCheck({authenticated, hashParams}) {
// Carries through whichever youth-mode flow (enable/disable) sent the user here via "忘记独立
// 密码?", if any -- IndependentPassword needs it to know whether to auto-continue into actually
// enabling youth mode, since the server hands back an enable-flow token unconditionally on every
// successful password (re)set regardless of why the caller got here. Absent (undefined) for the
// unrelated generic "身份验证" entry in acc-security-view.jsx, which should never touch youth mode.
const mode = hashParams && hashParams.ym;
const [phoneMask, setPhoneMask] = React.useState('');
const [challengeId, setChallengeId] = React.useState('');
const [code, setCode] = React.useState('');
@@ -581,7 +589,7 @@ function IdentityCheck({authenticated}) {
try {
const data = await verifySecurityChallenge(challengeId, code);
sessionStorage.setItem('im-action-token:' + IDENTITY_CHECK_SCENE, JSON.stringify(data));
navigate('independent-password');
navigate('independent-password', mode ? {ym: mode} : undefined);
} catch (err) {
setError(err.message || '验证码错误');
} finally {
@@ -681,7 +689,12 @@ function RealnameReminder() {
</div>;
}
function IndependentPassword({authenticated}) {
function IndependentPassword({authenticated, hashParams}) {
// Only the genuine "haven't set a password yet, setting one now to enable youth mode for the
// first time" flow should auto-continue into enableYouthMode below -- see IdentityCheck's
// comment on the same param for why this can't just be inferred from enable_action_token being
// present.
const mode = hashParams && hashParams.ym;
const [pin, setPin] = React.useState('');
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
@@ -704,8 +717,13 @@ function IndependentPassword({authenticated}) {
// 见 server/im_http.go:6178-6189)。原来这里把它整个丢掉、只弹一句"设置成功"就停在
// PIN 页——用户走完短信验证+设密码,青少年模式其实还是关的,得自己摸回设置页再输一遍。
// 现在拿着这个 token 把最后一步做完。
// enable_action_token comes back unconditionally on every successful (re)set, regardless of
// why the caller ended up here (server doesn't know or care) -- only the genuine "first time
// enabling" flow should spend it. Otherwise this silently flipped youth mode on for someone
// who was mid-way through disabling it, or who came from the unrelated generic "身份验证"
// entry with no youth-mode intent at all.
const enableToken = result && result.enable_action_token;
if (enableToken) {
if (enableToken && mode === 'enable') {
try {
const current = await getYouthMode();
await enableYouthMode(current.policy_version, enableToken);
@@ -719,6 +737,15 @@ function IndependentPassword({authenticated}) {
return;
}
}
if (mode === 'disable') {
// enableToken (if any) can't be spent here -- ConsumeActionToken requires an exact
// action='close_youth_mode' match, and this one is always tagged 'enable_youth_mode'. The
// user does know the fresh PIN now though, so send them straight back to actually finish
// disabling instead of leaving them stuck on this screen with nothing left to do.
setError(''); setToast('独立密码已重置,请使用新密码关闭青少年模式');
window.setTimeout(() => navigate('youth-mode-verify', {ym: 'disable'}), 800);
return;
}
setError(''); setToast('独立密码设置成功');
} catch (err) {
setError(err.message || '设置失败,请返回重新验证身份');
@@ -740,7 +767,7 @@ function IndependentPassword({authenticated}) {
}
export default function AuthOnboardingView({
view, onTenantResolved, authenticated = false,
view, hashParams, onTenantResolved, authenticated = false,
login, loginDisabled, persist, errorText, errorLevel,
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
}) {
@@ -753,11 +780,11 @@ export default function AuthOnboardingView({
case 'enterprise-feedback': return <EnterpriseFeedback />;
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} hashParams={hashParams} />;
case 'realname': return <Realname authenticated={authenticated} />;
case 'realname-verified': return <Realname authenticated={authenticated} />;
case 'realname-reminder': return <RealnameReminder />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} hashParams={hashParams} />;
default: return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
}
}
+13 -8
View File
@@ -7,7 +7,7 @@ import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
blockUser, unblockUser, getFileUrl, getBootstrap, getConversationContext, getConversationSettings,
updateConversationSettings, createUserQRCode
updateConversationSettings, createUserQRCode, fetchAllPages
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js';
@@ -112,12 +112,14 @@ function AddressBook({myUserId, onTopicSelected}) {
const fileAssistantTopic = bootstrap && bootstrap.entries && bootstrap.entries.file_assistant
&& bootstrap.entries.file_assistant.topic;
React.useEffect(() => {
getFriends().then(data => {
const list = data.items || [];
// getFriends() defaults to a 50-item page -- a plain single call silently truncated address
// books past that size, with no "load more" affordance to recover the rest.
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
setFriends(list);
}).catch(() => {}).finally(() => setLoading(false));
getFriendRequests('inbound', 'pending').then(data => setPendingCount((data.items || []).length)).catch(() => {});
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 groups = visible.reduce((result, item) => {
@@ -173,7 +175,8 @@ function FriendRequests({onToast}) {
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
getFriendRequests('all', 'all').then(data => setItems(data.items || [])).catch(() => {}).finally(() => setLoading(false));
fetchAllPages(cursor => getFriendRequests('all', 'all', cursor))
.then(setItems).catch(() => {}).finally(() => setLoading(false));
}, []);
const handle = (item, accept) => {
const nextStatus = accept ? 'accepted' : 'rejected';
@@ -510,8 +513,8 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 --
// folding an empty remark into display_name instead of returning a separate field), only the
// friends list endpoint's friendListItemPayload does. Look this friend up there instead.
getFriends().then(data => {
const entry = (data.items || []).find(item => item.user_id === uid);
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
const entry = list.find(item => item.user_id === uid);
if (entry) setRemark(entry.remark || '');
}).catch(() => {});
}, [uid]);
@@ -569,7 +572,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
function BlockList({onToast}) {
const [people, setPeople] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => { getBlocks().then(data => setPeople(data.items || [])).catch(() => {}).finally(() => setLoading(false)); }, []);
React.useEffect(() => {
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => {}).finally(() => setLoading(false));
}, []);
const remove = userId => {
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
.catch(err => onToast(err.message || '操作失败'));
+19 -17
View File
@@ -10,7 +10,7 @@ import {
updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
getGroupMemberProfile
getGroupMemberProfile, fetchAllPages
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
@@ -706,8 +706,9 @@ function AdminList({topic, onToast}) {
// Fetch the unfiltered member list rather than {role:'admin'} + a separate per-user profile lookup
// for the owner -- GET /users/{id}/profile 400s ("malformed request", field "user_id") when {id} is
// the caller's own uid, which broke this whenever the viewer is themselves the group's owner.
getGroupMembers(topic).then(data => {
const items = data.items || [];
// Members cap out at 30/page against a 128-member group limit -- an admin sitting past page 1
// used to vanish from this list entirely rather than the list just being incomplete.
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
writeCachedData('group-members:' + topic, items);
const d = deriveOwnerAndAdmins(items);
setOwner(d.owner);
@@ -744,8 +745,7 @@ function GroupMembers({topic, onToast}) {
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic).then(data => {
const items = data.items || [];
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
writeCachedData('group-members:' + topic, items);
setMembers(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
@@ -781,8 +781,8 @@ function MemberPicker({type, topic, onToast}) {
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, mute ? {muteEligible: true} : {eligibleRole: 'admin'})
.then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
fetchAllPages(cursor => getGroupMembers(topic, {cursor, ...(mute ? {muteEligible: true} : {eligibleRole: 'admin'})}))
.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));
@@ -842,15 +842,14 @@ function Muted({topic, onToast}) {
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
getGroupMutes(topic).then(data => {
const list = data.items || [];
fetchAllPages(cursor => getGroupMutes(topic, cursor)).then(list => {
writeCachedData('group-muted:' + topic, list);
setItems(list);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
const map = {};
const avatarMap = {};
(data.items || []).forEach(m => {
items.forEach(m => {
map[m.user_id] = m.group_nickname || m.display_name || m.user_id;
if (m.avatar_file_id) avatarMap[m.user_id] = {avatar_file_id: m.avatar_file_id};
});
@@ -896,7 +895,8 @@ function OwnerSelect({topic, onToast}) {
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, {eligibleRole: 'owner'}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
fetchAllPages(cursor => getGroupMembers(topic, {cursor, eligibleRole: 'owner'}))
.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));
@@ -1122,11 +1122,13 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
if (!topic || !myUserId) { setMyRole(null); return; }
const key = `group-my-role:${topic}:${myUserId}`;
setMyRole(readCachedData(key) || null);
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
const me = items.find(m => m.user_id === myUserId);
const role = me ? me.role : null;
// Used to look this up by paginating the full member list and finding myUserId in it (30/page
// against a 128-member cap) -- a real owner/admin sitting past page 1 read back as `null`,
// silently hiding every governance affordance gated on this value. getGroup's own `my_role`
// (already relied on elsewhere, e.g. the disband-group guard above) isn't paginated at all --
// it's always exactly "me", so this no longer needs the member list at all.
getGroup(topic).then(group => {
const role = group ? group.my_role : null;
writeCachedData(key, role);
setMyRole(role);
}).catch(() => {});
+11 -1
View File
@@ -1346,7 +1346,17 @@ class MessagesView extends React.Component {
if (!topic || !this.props.selectedSeqs.length) {
return;
}
topic.delMessagesList(this.props.selectedSeqs.slice(), true).then(_ => {
// A selected message may still be queued/uploading (no server-confirmed seq yet, same case
// context-menu.jsx's single-message deleteMessages guards with cancelSend before falling
// back to delMessagesList). delMessagesList's normalizeRanges silently drops local-only
// seqs instead of cancelling them, so left unfiltered a still-uploading attachment would
// survive "delete" and get sent normally once its upload finishes.
const remaining = this.props.selectedSeqs.filter(seq => !topic.cancelSend(seq));
if (!remaining.length) {
this.props.onExitSelectMode();
return;
}
topic.delMessagesList(remaining, true).then(_ => {
this.props.onExitSelectMode();
}).catch(err => {
this.props.onError(err.message, 'err');
+11 -4
View File
@@ -441,8 +441,12 @@ function DeleteAccountConfirm({onDeleteAccount, onToast}) {
Promise.resolve(onDeleteAccount()).catch(err => { onToast(err.message || '注销失败'); setBusy(false); });
};
if (step === 1) {
// im_be's replyDelUser -> hub.stopTopicsForUser hard-deletes every group topic this account
// owns (not just leaves them) -- confirmed against hub.go's topic.owner==uid branch. Callers
// in those groups get no advance warning and no forced-ownership-transfer prompt, so this has
// to be disclosed here since it's the only place the user sees before confirming.
return <ConfirmSheet icon="warning" title="注销账号"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。确定要继续吗?"
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?"
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
}
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
@@ -598,7 +602,7 @@ function YouthMode({onToast}) {
if (!settings) return;
if (!settings.password_set) {
onToast('请先设置独立密码');
window.setTimeout(() => navigate('identity-check'), 400);
window.setTimeout(() => navigate('identity-check', {ym: 'enable'}), 400);
return;
}
navigate('youth-mode-verify', {ym: 'enable'});
@@ -697,8 +701,11 @@ function YouthModeVerify({hashParams, onToast}) {
</button>
{/* 一旦设过密码,这个页面就是唯一入口,之前完全没有出路——真忘了密码只能不断输错直到被
限流,限流也只是让你等,不会给别的办法。identity-check 走的短信验证码本来就支持在
password_set:true 时重新设置密码(实测确认过),只是没有从这里链接过去。 */}
<button type="button" className="auth-text-button" onClick={() => navigate('identity-check')}>忘记独立密码?</button>
password_set:true 时重新设置密码(实测确认过),只是没有从这里链接过去。
必须带上当前 mode:IndependentPassword 设完新密码后,服务端每次都会无条件带回
enable_action_token(与调用者意图无关),原来因为不知道是从"关闭"这个流程点进来的,
会把这次重置误当成"继续开启",用户想关闭青少年模式却被弹"已开启"、状态也没有真的关掉。 */}
<button type="button" className="auth-text-button" onClick={() => navigate('identity-check', {ym: mode})}>忘记独立密码?</button>
</main>
</div>;
}
+1
View File
@@ -136,6 +136,7 @@ class SidepanelView extends React.PureComponent {
return <div id="sidepanel" className="auth-onboarding-panel">
<AuthOnboardingView
view={view}
hashParams={this.props.hashParams}
authenticated={!!this.props.myUserId}
login={this.props.login}
loginDisabled={this.props.loginDisabled}
+22 -2
View File
@@ -2053,6 +2053,15 @@ class TinodeWeb extends React.Component {
// (send-message.jsx) can gate on exactly the same real-world availability check as the existing
// "..." menu items, instead of re-deriving (and risking drifting from) the same three conditions.
canStartCall(topicName) {
// Never checked whether a call was already in progress -- the "..." menu entry and
// send-message.jsx's attachment-panel shortcut stayed clickable for the whole (very normal,
// product-supported) duration of a minimized call. A second click re-arms
// handleStartVideoCall/handleStartAudioCall's 60s ring timeout and forces callState back to
// CALL_STATE_OUTGOING_INITATED with no visible effect (the already-mounted CallPanel doesn't
// react to that prop), silently hanging up the real, healthy call a full minute later.
if (this.state.callState != CALL_STATE_NONE) {
return false;
}
const topic = this.tinode.getTopic(topicName);
if (!topic || topic._deleted || !Tinode.isP2PTopicName(topicName)) {
return false;
@@ -2524,7 +2533,15 @@ class TinodeWeb extends React.Component {
// arrives (or armCallRingTimeout's hang-up firing before it) leaves this call's callSeq
// pointing at whatever the *previous* call's seq was, and the next outgoing call could read
// that stale value for the brief window before its own ack lands.
callSeq: undefined
callSeq: undefined,
// handleCallAccept's "topic not subscribed yet" branch (below) sets this and waits for
// handleNewTopicCreated to both flip callState to CALL_STATE_IN_PROGRESS and clear this flag
// once that subscription succeeds -- but a call closed/abandoned (rejected, or the subscribe
// itself failing) before that ever happens left this stuck at true with no owner left to clear
// it. It would then get read by the *next*, entirely unrelated topic subscription that happens
// to succeed, incorrectly forcing callState to CALL_STATE_IN_PROGRESS for a call that was
// already given up on.
callShouldStart: false
});
}
@@ -2567,7 +2584,10 @@ class TinodeWeb extends React.Component {
callTopic: null,
callState: CALL_STATE_NONE,
callSeq: null,
callAudioOnly: undefined
callAudioOnly: undefined,
// Same stuck-flag risk handleCallClose() guards against -- this call is being given up
// on here just as much as a local close/reject would, so it needs the same cleanup.
callShouldStart: false
});
return;
}
+2 -2
View File
@@ -76,8 +76,8 @@ const nameCache = new Map();
function resolveUserName(id, onDone) {
if (!id) return;
if (nameCache.has(id)) { onDone(nameCache.get(id)); return; }
const withPrefix = id.startsWith('usr') ? id : 'usr' + id;
getUserProfile(id).catch(() => getUserProfile(withPrefix)).then(p => {
const withPrefix = id.startsWith('usr') ? null : 'usr' + id;
getUserProfile(id).catch(err => withPrefix ? getUserProfile(withPrefix) : Promise.reject(err)).then(p => {
const name = (p && (p.display_name || p.nickname || p.public_account)) || id;
nameCache.set(id, name);
onDone(name);
+20 -1
View File
@@ -19,6 +19,11 @@ const THUMB_COLOR = '#1781d7'; // average between dark and light accent colors.
// Minimum number of amplitude bars to draw.
const MIN_PREVIEW_LENGTH = 16;
// 7.5 "列表中同时只能有一条语音播放":favorites/history 列表和聊天气泡里的每条语音各自是一个独立
// AudioPlayer 实例,彼此不知道对方的存在。Module-scoped(不是某个列表父组件的 state)所以跨 tab、
// 跨收藏/聊天记录也生效,不只是同一个列表内部。
let activeAudioPlayer = null;
const messages = defineMessages({
icon_title_play: {
id: 'icon_title_play',
@@ -69,6 +74,9 @@ class AudioPlayer extends React.PureComponent {
}
componentWillUnmount() {
if (activeAudioPlayer === this) {
activeAudioPlayer = null;
}
if (this.audioPlayer) {
this.audioPlayer.onloadedmetadata = null;
this.audioPlayer.ontimeupdate = null;
@@ -78,6 +86,14 @@ class AudioPlayer extends React.PureComponent {
}
}
// Called on the previously-playing instance by whichever other instance is about to start.
pauseForOtherPlayer() {
if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.setState({playing: false});
}
componentDidUpdate(prevProps) {
if (this.props.src != prevProps.src) {
this.initAudio();
@@ -100,7 +116,10 @@ class AudioPlayer extends React.PureComponent {
});
this.audioPlayer.onended = _ => {
this.audioPlayer.currentTime = 0;
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)})
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)});
if (activeAudioPlayer === this) {
activeAudioPlayer = null;
}
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ class AudioRecorder extends React.PureComponent {
if (duration > MAX_DURATION) {
this.startedOn = null;
this.mediaRecorder.pause();
this.durationMillis += Date.now() - this.startedOn;
this.durationMillis = duration;
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)});
}
+1
View File
@@ -196,6 +196,7 @@ class BaseChatMessage extends React.PureComponent {
seq: this.props.seq,
replace: this.props.edited ? parseInt(this.props.edited.split(':')[1]) : 0,
content: this.props.content,
head: this.props.head,
userFrom: this.props.userFrom,
userName: this.props.userName,
y: e.pageY,
+9
View File
@@ -608,6 +608,15 @@ class ContextMenu extends React.Component {
return;
}
const msg = topic.createMessage(params.content, false);
// createMessage only ever fills in head.mime for non-plain-text content -- the original
// message's x-im-type/reply/mentions/mention_all live in params.head (set by
// chat-message.jsx's handleContextClick from the props it was rendered with) and would
// otherwise be silently dropped, unlike the auto-retry path (messages-view.jsx's retrySend)
// which already forwards the original head. Same merge tinode-web.jsx's
// sendMessageToTopic does for a fresh send.
if (params.head) {
msg.head = Object.assign(msg.head || {}, params.head);
}
return topic.publishDraft(msg).catch(err => {
if (errorHandler) {
errorHandler(err.message, 'err');