第七轮深度测试:修复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:
co-authored by
Claude Sonnet 5
parent
f42b8536e5
commit
bede45fd7f
@@ -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});
|
||||
|
||||
@@ -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 || (() => {})} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '操作失败'));
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
+101
-38
@@ -3382,6 +3382,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ disableYouthMode: function() { return /* binding */ disableYouthMode; },
|
||||
/* harmony export */ enableYouthMode: function() { return /* binding */ enableYouthMode; },
|
||||
/* harmony export */ favoriteMessages: function() { return /* binding */ favoriteMessages; },
|
||||
/* harmony export */ fetchAllPages: function() { return /* binding */ fetchAllPages; },
|
||||
/* harmony export */ fetchFileState: function() { return /* binding */ fetchFileState; },
|
||||
/* harmony export */ forwardFavorites: function() { return /* binding */ forwardFavorites; },
|
||||
/* harmony export */ forwardMessages: function() { return /* binding */ forwardMessages; },
|
||||
@@ -3640,6 +3641,20 @@ function translateReason(reason, details) {
|
||||
const idem = () => ({
|
||||
'Idempotency-Key': requestId()
|
||||
});
|
||||
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;
|
||||
}
|
||||
const imGet = (path, opts) => imRequest(path, opts);
|
||||
const imPost = (path, body, opts = {}) => imRequest(path, {
|
||||
method: 'POST',
|
||||
@@ -5583,7 +5598,7 @@ const PHONE_RE = /^\d{11}$/;
|
||||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||||
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const navigate = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].navigateTo(view);
|
||||
const navigate = (view, params) => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].composeUrlHash([view], params || {}));
|
||||
function Icon({
|
||||
children,
|
||||
className = ''
|
||||
@@ -6474,8 +6489,10 @@ function PasswordReset() {
|
||||
}
|
||||
const IDENTITY_CHECK_SCENE = 'set_independent_password';
|
||||
function IdentityCheck({
|
||||
authenticated
|
||||
authenticated,
|
||||
hashParams
|
||||
}) {
|
||||
const mode = hashParams && hashParams.ym;
|
||||
const [phoneMask, setPhoneMask] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||
const [challengeId, setChallengeId] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||
const [code, setCode] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||
@@ -6520,7 +6537,9 @@ function IdentityCheck({
|
||||
try {
|
||||
const data = await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.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 {
|
||||
@@ -6730,8 +6749,10 @@ function RealnameReminder() {
|
||||
}, void 0, true);
|
||||
}
|
||||
function IndependentPassword({
|
||||
authenticated
|
||||
authenticated,
|
||||
hashParams
|
||||
}) {
|
||||
const mode = hashParams && hashParams.ym;
|
||||
const [pin, setPin] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||
const [error, setError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||
const [toast, setToast] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
|
||||
@@ -6755,7 +6776,7 @@ function IndependentPassword({
|
||||
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
|
||||
setPin('');
|
||||
const enableToken = result && result.enable_action_token;
|
||||
if (enableToken) {
|
||||
if (enableToken && mode === 'enable') {
|
||||
try {
|
||||
const current = await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.getYouthMode)();
|
||||
await (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_2__.enableYouthMode)(current.policy_version, enableToken);
|
||||
@@ -6769,6 +6790,14 @@ function IndependentPassword({
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mode === 'disable') {
|
||||
setError('');
|
||||
setToast('独立密码已重置,请使用新密码关闭青少年模式');
|
||||
window.setTimeout(() => navigate('youth-mode-verify', {
|
||||
ym: 'disable'
|
||||
}), 800);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setToast('独立密码设置成功');
|
||||
} catch (err) {
|
||||
@@ -6818,6 +6847,7 @@ function IndependentPassword({
|
||||
}
|
||||
function AuthOnboardingView({
|
||||
view,
|
||||
hashParams,
|
||||
onTenantResolved,
|
||||
authenticated = false,
|
||||
login,
|
||||
@@ -6862,7 +6892,8 @@ function AuthOnboardingView({
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(PasswordReset, {}, void 0, false);
|
||||
case 'identity-check':
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(IdentityCheck, {
|
||||
authenticated: authenticated
|
||||
authenticated: authenticated,
|
||||
hashParams: hashParams
|
||||
}, void 0, false);
|
||||
case 'realname':
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(Realname, {
|
||||
@@ -6876,7 +6907,8 @@ function AuthOnboardingView({
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(RealnameReminder, {}, void 0, false);
|
||||
case 'independent-password':
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(IndependentPassword, {
|
||||
authenticated: authenticated
|
||||
authenticated: authenticated,
|
||||
hashParams: hashParams
|
||||
}, void 0, false);
|
||||
default:
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(TenantEntry, {
|
||||
@@ -7624,14 +7656,13 @@ function AddressBook({
|
||||
const [bootstrap] = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.useCachedData)(myUserId ? 'bootstrap:' + myUserId : null, _lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getBootstrap, [myUserId]);
|
||||
const fileAssistantTopic = bootstrap && bootstrap.entries && bootstrap.entries.file_assistant && bootstrap.entries.file_assistant.topic;
|
||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriends)().then(data => {
|
||||
const list = data.items || [];
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriends)('', cursor)).then(list => {
|
||||
if (friendsCacheKey) {
|
||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)(friendsCacheKey, list);
|
||||
}
|
||||
setFriends(list);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriendRequests)('inbound', 'pending').then(data => setPendingCount((data.items || []).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 groups = visible.reduce((result, item) => {
|
||||
@@ -7759,7 +7790,7 @@ function FriendRequests({
|
||||
const [items, setItems] = react__WEBPACK_IMPORTED_MODULE_0___default().useState([]);
|
||||
const [loading, setLoading] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(true);
|
||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriendRequests)('all', 'all').then(data => setItems(data.items || [])).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)('all', 'all', cursor)).then(setItems).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
const handle = (item, accept) => {
|
||||
const nextStatus = accept ? 'accepted' : 'rejected';
|
||||
@@ -8362,8 +8393,8 @@ function Settings({
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriends)().then(data => {
|
||||
const entry = (data.items || []).find(item => item.user_id === uid);
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getFriends)('', cursor)).then(list => {
|
||||
const entry = list.find(item => item.user_id === uid);
|
||||
if (entry) setRemark(entry.remark || '');
|
||||
}).catch(() => {});
|
||||
}, [uid]);
|
||||
@@ -8522,7 +8553,7 @@ function BlockList({
|
||||
const [people, setPeople] = react__WEBPACK_IMPORTED_MODULE_0___default().useState([]);
|
||||
const [loading, setLoading] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(true);
|
||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getBlocks)().then(data => setPeople(data.items || [])).catch(() => {}).finally(() => setLoading(false));
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getBlocks)(cursor)).then(setPeople).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
const remove = userId => {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.unblockUser)(userId).then(() => {
|
||||
@@ -12006,8 +12037,9 @@ function AdminList({
|
||||
setOwner(d.owner);
|
||||
setAdmins(d.admins);
|
||||
}
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic).then(data => {
|
||||
const items = data.items || [];
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic, {
|
||||
cursor
|
||||
})).then(items => {
|
||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-members:' + topic, items);
|
||||
const d = deriveOwnerAndAdmins(items);
|
||||
setOwner(d.owner);
|
||||
@@ -12094,8 +12126,9 @@ function GroupMembers({
|
||||
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||
if (!topic) return;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic).then(data => {
|
||||
const items = data.items || [];
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic, {
|
||||
cursor
|
||||
})).then(items => {
|
||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-members:' + topic, items);
|
||||
setMembers(items);
|
||||
}).catch(() => {
|
||||
@@ -12158,11 +12191,14 @@ function MemberPicker({
|
||||
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||
if (!topic) return;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic, mute ? {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic, {
|
||||
cursor,
|
||||
...(mute ? {
|
||||
muteEligible: true
|
||||
} : {
|
||||
eligibleRole: 'admin'
|
||||
}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false))).catch(() => {
|
||||
})
|
||||
})).then(items => setMembers(items.filter(m => m.eligible !== false))).catch(() => {
|
||||
onToast('加载失败');
|
||||
setLoadError(true);
|
||||
});
|
||||
@@ -12242,18 +12278,19 @@ function Muted({
|
||||
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
||||
const load = react__WEBPACK_IMPORTED_MODULE_0___default().useCallback(() => {
|
||||
if (!topic) return;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMutes)(topic).then(data => {
|
||||
const list = data.items || [];
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMutes)(topic, cursor)).then(list => {
|
||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-muted:' + topic, list);
|
||||
setItems(list);
|
||||
}).catch(() => {
|
||||
onToast('加载失败');
|
||||
setLoadError(true);
|
||||
});
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic).then(data => {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.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
|
||||
@@ -12324,9 +12361,10 @@ function OwnerSelect({
|
||||
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
||||
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
|
||||
if (!topic) return;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic, {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.fetchAllPages)(cursor => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic, {
|
||||
cursor,
|
||||
eligibleRole: 'owner'
|
||||
}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false))).catch(() => {
|
||||
})).then(items => setMembers(items.filter(m => m.eligible !== false))).catch(() => {
|
||||
onToast('加载失败');
|
||||
setLoadError(true);
|
||||
});
|
||||
@@ -12793,11 +12831,8 @@ function GroupManagementView({
|
||||
}
|
||||
const key = `group-my-role:${topic}:${myUserId}`;
|
||||
setMyRole((0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.readCachedData)(key) || null);
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic).then(data => {
|
||||
const items = data.items || [];
|
||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-members:' + topic, items);
|
||||
const me = items.find(m => m.user_id === myUserId);
|
||||
const role = me ? me.role : null;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroup)(topic).then(group => {
|
||||
const role = group ? group.my_role : null;
|
||||
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)(key, role);
|
||||
setMyRole(role);
|
||||
}).catch(() => {});
|
||||
@@ -15738,7 +15773,7 @@ function DeleteAccountConfirm({
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(ConfirmSheet, {
|
||||
icon: "warning",
|
||||
title: "注销账号",
|
||||
body: "注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。确定要继续吗?",
|
||||
body: "注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?",
|
||||
confirmLabel: "继续",
|
||||
onConfirm: () => setStep(2),
|
||||
onCancel: () => navigate('android-settings')
|
||||
@@ -16020,7 +16055,9 @@ function YouthMode({
|
||||
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', {
|
||||
@@ -16151,7 +16188,9 @@ function YouthModeVerify({
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
className: "auth-text-button",
|
||||
onClick: () => navigate('identity-check'),
|
||||
onClick: () => navigate('identity-check', {
|
||||
ym: mode
|
||||
}),
|
||||
children: "忘记独立密码?"
|
||||
}, void 0, false)]
|
||||
}, void 0, true)]
|
||||
@@ -17252,6 +17291,7 @@ class SidepanelView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
className: "auth-onboarding-panel",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_25__.jsxDEV)(_auth_onboarding_view_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
|
||||
view: view,
|
||||
hashParams: this.props.hashParams,
|
||||
authenticated: !!this.props.myUserId,
|
||||
login: this.props.login,
|
||||
loginDisabled: this.props.loginDisabled,
|
||||
@@ -19317,6 +19357,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
});
|
||||
}
|
||||
canStartCall(topicName) {
|
||||
if (this.state.callState != _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_NONE) {
|
||||
return false;
|
||||
}
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (!topic || topic._deleted || !tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isP2PTopicName(topicName)) {
|
||||
return false;
|
||||
@@ -19729,7 +19772,8 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
callTopic: undefined,
|
||||
callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_NONE,
|
||||
callAudioOnly: undefined,
|
||||
callSeq: undefined
|
||||
callSeq: undefined,
|
||||
callShouldStart: false
|
||||
});
|
||||
}
|
||||
handleCallAccept(topicName, setCallTopic, callSeq) {
|
||||
@@ -19766,7 +19810,8 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
callTopic: null,
|
||||
callState: _constants_js__WEBPACK_IMPORTED_MODULE_13__.CALL_STATE_NONE,
|
||||
callSeq: null,
|
||||
callAudioOnly: undefined
|
||||
callAudioOnly: undefined,
|
||||
callShouldStart: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -20426,8 +20471,8 @@ function resolveUserName(id, onDone) {
|
||||
onDone(nameCache.get(id));
|
||||
return;
|
||||
}
|
||||
const withPrefix = id.startsWith('usr') ? id : 'usr' + id;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getUserProfile)(id).catch(() => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getUserProfile)(withPrefix)).then(p => {
|
||||
const withPrefix = id.startsWith('usr') ? null : 'usr' + id;
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getUserProfile)(id).catch(err => withPrefix ? (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.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);
|
||||
@@ -22458,6 +22503,7 @@ const BAR_COLOR = '#888A';
|
||||
const BAR_COLOR_DARK = '#666C';
|
||||
const THUMB_COLOR = '#1781d7';
|
||||
const MIN_PREVIEW_LENGTH = 16;
|
||||
let activeAudioPlayer = null;
|
||||
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
|
||||
icon_title_play: {
|
||||
id: 'icon_title_play',
|
||||
@@ -22498,6 +22544,9 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.initCanvas();
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (activeAudioPlayer === this) {
|
||||
activeAudioPlayer = null;
|
||||
}
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.onloadedmetadata = null;
|
||||
this.audioPlayer.ontimeupdate = null;
|
||||
@@ -22506,6 +22555,14 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.audioPlayer = null;
|
||||
}
|
||||
}
|
||||
pauseForOtherPlayer() {
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.pause();
|
||||
}
|
||||
this.setState({
|
||||
playing: false
|
||||
});
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.src != prevProps.src) {
|
||||
this.initAudio();
|
||||
@@ -22534,6 +22591,9 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
playing: false,
|
||||
currentTime: (0,_lib_strformat__WEBPACK_IMPORTED_MODULE_2__.secondsToTime)(0, this.state.longMin)
|
||||
});
|
||||
if (activeAudioPlayer === this) {
|
||||
activeAudioPlayer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
initCanvas() {
|
||||
@@ -24962,6 +25022,9 @@ class ContextMenu extends (react__WEBPACK_IMPORTED_MODULE_0___default().Componen
|
||||
return;
|
||||
}
|
||||
const msg = topic.createMessage(params.content, false);
|
||||
if (params.head) {
|
||||
msg.head = Object.assign(msg.head || {}, params.head);
|
||||
}
|
||||
return topic.publishDraft(msg).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1224,7 +1224,12 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
if (!topic || !this.props.selectedSeqs.length) {
|
||||
return;
|
||||
}
|
||||
topic.delMessagesList(this.props.selectedSeqs.slice(), true).then(_ => {
|
||||
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');
|
||||
@@ -2719,6 +2724,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -140,7 +140,7 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
if (duration > _config_js__WEBPACK_IMPORTED_MODULE_6__.MAX_DURATION) {
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.pause();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.durationMillis = duration;
|
||||
this.setState({
|
||||
enabled: false,
|
||||
recording: false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user